Autofocus, Inset handling
This commit is contained in:
@@ -170,6 +170,7 @@
|
||||
android:name=".ui.MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:theme="@style/NoActionBarAppTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.activity.viewModels
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import xyz.quaver.pupil.ui.composable.MainApp
|
||||
@@ -39,6 +40,8 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
val viewModel: MainViewModel by viewModels()
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
val windowSize = calculateWindowSizeClass(this)
|
||||
|
||||
@@ -5,7 +5,11 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
@@ -105,13 +109,15 @@ private fun MainNavigationWrapper(
|
||||
}
|
||||
|
||||
if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
|
||||
PermanentNavigationDrawer(drawerContent = {
|
||||
PermanentNavigationDrawerContent(
|
||||
selectedDestination = uiState.currentDestination,
|
||||
navigationContentPosition = navigationContentPosition,
|
||||
navigateToDestination = navigateToDestination
|
||||
)
|
||||
}) {
|
||||
PermanentNavigationDrawer(
|
||||
drawerContent = {
|
||||
PermanentNavigationDrawerContent(
|
||||
selectedDestination = uiState.currentDestination,
|
||||
navigationContentPosition = navigationContentPosition,
|
||||
navigateToDestination = navigateToDestination
|
||||
)
|
||||
}
|
||||
) {
|
||||
MainContent(
|
||||
navigationType = navigationType,
|
||||
contentType = contentType,
|
||||
@@ -180,6 +186,12 @@ fun MainContent(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
.run {
|
||||
if (navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||
this.consumeWindowInsets(WindowInsets.ime)
|
||||
.consumeWindowInsets(WindowInsets.navigationBars)
|
||||
} else this
|
||||
}
|
||||
) {
|
||||
MainScreen(
|
||||
contentType = contentType,
|
||||
|
||||
@@ -4,17 +4,20 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MenuOpen
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -34,7 +37,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -237,7 +239,7 @@ fun BottomNavigationBar(
|
||||
selectedDestination: MainDestination,
|
||||
navigateToDestination: (MainDestination) -> Unit
|
||||
) {
|
||||
NavigationBar(modifier = Modifier.fillMaxWidth()) {
|
||||
NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = selectedDestination.route == destination.route,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -39,24 +41,33 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.networking.validNamespace
|
||||
@@ -66,7 +77,6 @@ import xyz.quaver.pupil.ui.theme.Gray300
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
import xyz.quaver.pupil.ui.theme.Red300
|
||||
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||
import kotlin.math.exp
|
||||
|
||||
private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) {
|
||||
is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag)
|
||||
@@ -102,10 +112,12 @@ fun coalesceTags(oldTag: EditableSearchQueryState.Tag?, newTag: EditableSearchQu
|
||||
sealed interface EditableSearchQueryState {
|
||||
class Tag(
|
||||
namespace: String? = null,
|
||||
tag: String = ""
|
||||
tag: String = "",
|
||||
expanded: Boolean = false
|
||||
): EditableSearchQueryState {
|
||||
val namespace = mutableStateOf(namespace)
|
||||
val tag = mutableStateOf(tag)
|
||||
val expanded = mutableStateOf(expanded)
|
||||
}
|
||||
|
||||
class And(
|
||||
@@ -138,6 +150,8 @@ sealed interface EditableSearchQueryState {
|
||||
fun EditableTagChip(
|
||||
state: EditableSearchQueryState.Tag,
|
||||
isFavorite: Boolean = false,
|
||||
autoFocus: Boolean = true,
|
||||
requestScrollTo: (Float) -> Unit,
|
||||
leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) },
|
||||
rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) },
|
||||
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag ->
|
||||
@@ -149,10 +163,20 @@ fun EditableTagChip(
|
||||
)
|
||||
}
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var namespace by state.namespace
|
||||
var tag by state.tag
|
||||
var expanded by state.expanded
|
||||
var wasFocused by remember { mutableStateOf(false) }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var positionY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (!expanded) {
|
||||
wasFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
val surfaceColor by animateColorAsState(
|
||||
when {
|
||||
@@ -175,6 +199,9 @@ fun EditableTagChip(
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
positionY = it.positionInRoot().y
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = surfaceColor
|
||||
) {
|
||||
@@ -217,20 +244,34 @@ fun EditableTagChip(
|
||||
)
|
||||
}
|
||||
|
||||
var selection by remember { mutableStateOf(TextRange.Zero) }
|
||||
var selection by remember { mutableStateOf(TextRange(tag.length)) }
|
||||
var composition by remember { mutableStateOf<TextRange?>(null) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val textFieldValue = remember(tag, selection, composition) {
|
||||
TextFieldValue(tag, selection, composition)
|
||||
}
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (autoFocus && expanded) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
leadingIcon = {
|
||||
TagChipIcon(SearchQuery.Tag(namespace, tag))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onKeyEvent { event ->
|
||||
if (event.key == Key.Backspace && tag.isEmpty()) {
|
||||
val newTag = namespace?.dropLast(1) ?: ""
|
||||
@@ -240,6 +281,18 @@ fun EditableTagChip(
|
||||
composition = null
|
||||
true
|
||||
} else false
|
||||
}
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { event ->
|
||||
if (event.isFocused) {
|
||||
wasFocused = true
|
||||
coroutineScope.launch {
|
||||
delay(300)
|
||||
requestScrollTo(positionY)
|
||||
}
|
||||
} else if (wasFocused) {
|
||||
expanded = false
|
||||
}
|
||||
},
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
@@ -247,9 +300,10 @@ fun EditableTagChip(
|
||||
}
|
||||
),
|
||||
onValueChange = { newTextValue ->
|
||||
val newTag = newTextValue.text.lowercase()
|
||||
tag = if (namespace == null && newTag.trim() in validNamespace) {
|
||||
namespace = newTag.trim()
|
||||
val newTag = newTextValue.text
|
||||
val possibleNamespace = newTag.dropLast(1).lowercase().trim()
|
||||
tag = if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) {
|
||||
namespace = possibleNamespace
|
||||
""
|
||||
} else newTag
|
||||
selection = newTextValue.selection
|
||||
@@ -310,10 +364,10 @@ fun NewQueryChip(
|
||||
opened = false
|
||||
}
|
||||
HorizontalDivider()
|
||||
if (currentQuery !is EditableSearchQueryState.Tag) {
|
||||
if (currentQuery !is EditableSearchQueryState.Tag && currentQuery !is EditableSearchQueryState.And) {
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.search_add_query_item_tag)) {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Tag())
|
||||
onNewQuery(EditableSearchQueryState.Tag(expanded = true))
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.And) {
|
||||
@@ -351,11 +405,14 @@ fun NewQueryChip(
|
||||
fun QueryEditorQueryView(
|
||||
state: EditableSearchQueryState,
|
||||
onQueryRemove: (EditableSearchQueryState) -> Unit,
|
||||
requestScrollTo: (Float) -> Unit,
|
||||
requestScrollBy: (Float) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is EditableSearchQueryState.Tag -> {
|
||||
EditableTagChip(
|
||||
state,
|
||||
requestScrollTo = requestScrollTo,
|
||||
rightIcon = {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
@@ -404,7 +461,9 @@ fun QueryEditorQueryView(
|
||||
if (index != 0) { Text("+", modifier = Modifier.padding(horizontal = 8.dp)) }
|
||||
QueryEditorQueryView(
|
||||
subQueryState,
|
||||
onQueryRemove = { state.queries.remove(it) }
|
||||
onQueryRemove = { state.queries.remove(it) },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
@@ -429,6 +488,24 @@ fun QueryEditorQueryView(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
val newSearchQuery = remember { EditableSearchQueryState.Tag() }
|
||||
|
||||
var newQueryNamespace by newSearchQuery.namespace
|
||||
var newQueryTag by newSearchQuery.tag
|
||||
var newQueryExpanded by newSearchQuery.expanded
|
||||
|
||||
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
|
||||
LaunchedEffect(newQueryExpanded) {
|
||||
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||
state.queries.add(EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag))
|
||||
newQueryNamespace = null
|
||||
newQueryTag = ""
|
||||
newQueryExpanded = true
|
||||
requestScrollBy(offset)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -446,9 +523,27 @@ fun QueryEditorQueryView(
|
||||
state.queries.forEach { subQuery ->
|
||||
QueryEditorQueryView(
|
||||
subQuery,
|
||||
onQueryRemove = { state.queries.remove(it) }
|
||||
onQueryRemove = { state.queries.remove(it) },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
EditableTagChip(
|
||||
newSearchQuery,
|
||||
requestScrollTo = requestScrollTo,
|
||||
rightIcon = {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp)
|
||||
.clickable {
|
||||
onQueryRemove(state)
|
||||
},
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
)
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
state.queries.add(newQueryState)
|
||||
}
|
||||
@@ -491,7 +586,9 @@ fun QueryEditorQueryView(
|
||||
if (subQueryStateSnapshot != null) {
|
||||
QueryEditorQueryView(
|
||||
subQueryStateSnapshot,
|
||||
onQueryRemove = { subQueryState = null }
|
||||
onQueryRemove = { subQueryState = null },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -518,9 +615,19 @@ fun QueryEditor(
|
||||
) {
|
||||
var rootQuery by state.query
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var topY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val scrollOffset = with (LocalDensity.current) { 16.dp.toPx() }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.onGloballyPositioned {
|
||||
topY = it.positionInRoot().y
|
||||
}
|
||||
.verticalScroll(scrollState)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
@@ -528,7 +635,19 @@ fun QueryEditor(
|
||||
if (rootQuerySnapshot != null) {
|
||||
QueryEditorQueryView(
|
||||
state = rootQuerySnapshot,
|
||||
onQueryRemove = { rootQuery = null }
|
||||
onQueryRemove = { rootQuery = null },
|
||||
requestScrollTo = { target ->
|
||||
val topYSnapshot = topY
|
||||
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollBy(target - topYSnapshot - scrollOffset, spring(stiffness = Spring.StiffnessLow))
|
||||
}
|
||||
},
|
||||
requestScrollBy = { value ->
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollBy(value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user