Autofocus, Inset handling

This commit is contained in:
tom5079
2024-03-03 22:18:26 -08:00
parent 419c8fc644
commit ab3e6466d5
5 changed files with 164 additions and 27 deletions

View File

@@ -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" />

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
}
}
)
}