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:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
android:theme="@style/NoActionBarAppTheme" android:theme="@style/NoActionBarAppTheme"
android:windowSoftInputMode="adjustResize"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.view.WindowCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.adaptive.calculateDisplayFeatures import com.google.accompanist.adaptive.calculateDisplayFeatures
import xyz.quaver.pupil.ui.composable.MainApp import xyz.quaver.pupil.ui.composable.MainApp
@@ -39,6 +40,8 @@ class MainActivity : BaseActivity() {
val viewModel: MainViewModel by viewModels() val viewModel: MainViewModel by viewModels()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
AppTheme { AppTheme {
val windowSize = calculateWindowSizeClass(this) 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
@@ -105,13 +109,15 @@ private fun MainNavigationWrapper(
} }
if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) { if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
PermanentNavigationDrawer(drawerContent = { PermanentNavigationDrawer(
drawerContent = {
PermanentNavigationDrawerContent( PermanentNavigationDrawerContent(
selectedDestination = uiState.currentDestination, selectedDestination = uiState.currentDestination,
navigationContentPosition = navigationContentPosition, navigationContentPosition = navigationContentPosition,
navigateToDestination = navigateToDestination navigateToDestination = navigateToDestination
) )
}) { }
) {
MainContent( MainContent(
navigationType = navigationType, navigationType = navigationType,
contentType = contentType, contentType = contentType,
@@ -180,6 +186,12 @@ fun MainContent(
) { ) {
Box( Box(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
.run {
if (navigationType == NavigationType.BOTTOM_NAVIGATION) {
this.consumeWindowInsets(WindowInsets.ime)
.consumeWindowInsets(WindowInsets.navigationBars)
} else this
}
) { ) {
MainScreen( MainScreen(
contentType = contentType, contentType = contentType,

View File

@@ -4,17 +4,20 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuOpen import androidx.compose.material.icons.automirrored.filled.MenuOpen
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MenuOpen
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.Layout
import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.layoutId import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -237,7 +239,7 @@ fun BottomNavigationBar(
selectedDestination: MainDestination, selectedDestination: MainDestination,
navigateToDestination: (MainDestination) -> Unit navigateToDestination: (MainDestination) -> Unit
) { ) {
NavigationBar(modifier = Modifier.fillMaxWidth()) { NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) {
mainDestinations.forEach { destination -> mainDestinations.forEach { destination ->
NavigationBarItem( NavigationBarItem(
selected = selectedDestination.route == destination.route, selected = selectedDestination.route == destination.route,

View File

@@ -1,10 +1,11 @@
package xyz.quaver.pupil.ui.composable package xyz.quaver.pupil.ui.composable
import android.util.Log
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState 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.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent 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.res.stringResource
import androidx.compose.ui.text.TextRange 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.input.TextFieldValue
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.networking.validNamespace 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.Pink600
import xyz.quaver.pupil.ui.theme.Red300 import xyz.quaver.pupil.ui.theme.Red300
import xyz.quaver.pupil.ui.theme.Yellow400 import xyz.quaver.pupil.ui.theme.Yellow400
import kotlin.math.exp
private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) { private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) {
is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag) is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag)
@@ -102,10 +112,12 @@ fun coalesceTags(oldTag: EditableSearchQueryState.Tag?, newTag: EditableSearchQu
sealed interface EditableSearchQueryState { sealed interface EditableSearchQueryState {
class Tag( class Tag(
namespace: String? = null, namespace: String? = null,
tag: String = "" tag: String = "",
expanded: Boolean = false
): EditableSearchQueryState { ): EditableSearchQueryState {
val namespace = mutableStateOf(namespace) val namespace = mutableStateOf(namespace)
val tag = mutableStateOf(tag) val tag = mutableStateOf(tag)
val expanded = mutableStateOf(expanded)
} }
class And( class And(
@@ -138,6 +150,8 @@ sealed interface EditableSearchQueryState {
fun EditableTagChip( fun EditableTagChip(
state: EditableSearchQueryState.Tag, state: EditableSearchQueryState.Tag,
isFavorite: Boolean = false, isFavorite: Boolean = false,
autoFocus: Boolean = true,
requestScrollTo: (Float) -> Unit,
leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) }, leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) },
rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) }, rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) },
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag ->
@@ -149,10 +163,20 @@ fun EditableTagChip(
) )
} }
) { ) {
val coroutineScope = rememberCoroutineScope()
var namespace by state.namespace var namespace by state.namespace
var tag by state.tag 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( val surfaceColor by animateColorAsState(
when { when {
@@ -175,6 +199,9 @@ fun EditableTagChip(
) )
Surface( Surface(
modifier = Modifier.onGloballyPositioned {
positionY = it.positionInRoot().y
},
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = surfaceColor 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) } var composition by remember { mutableStateOf<TextRange?>(null) }
val focusRequester = remember { FocusRequester() }
val textFieldValue = remember(tag, selection, composition) { val textFieldValue = remember(tag, selection, composition) {
TextFieldValue(tag, selection, composition) TextFieldValue(tag, selection, composition)
} }
LaunchedEffect(expanded) {
if (autoFocus && expanded) {
focusRequester.requestFocus()
}
}
OutlinedTextField( OutlinedTextField(
value = textFieldValue, value = textFieldValue,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(
autoCorrect = false,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
leadingIcon = { leadingIcon = {
TagChipIcon(SearchQuery.Tag(namespace, tag)) TagChipIcon(SearchQuery.Tag(namespace, tag))
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.onKeyEvent { event -> .onKeyEvent { event ->
if (event.key == Key.Backspace && tag.isEmpty()) { if (event.key == Key.Backspace && tag.isEmpty()) {
val newTag = namespace?.dropLast(1) ?: "" val newTag = namespace?.dropLast(1) ?: ""
@@ -240,6 +281,18 @@ fun EditableTagChip(
composition = null composition = null
true true
} else false } else false
}
.focusRequester(focusRequester)
.onFocusChanged { event ->
if (event.isFocused) {
wasFocused = true
coroutineScope.launch {
delay(300)
requestScrollTo(positionY)
}
} else if (wasFocused) {
expanded = false
}
}, },
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onDone = { onDone = {
@@ -247,9 +300,10 @@ fun EditableTagChip(
} }
), ),
onValueChange = { newTextValue -> onValueChange = { newTextValue ->
val newTag = newTextValue.text.lowercase() val newTag = newTextValue.text
tag = if (namespace == null && newTag.trim() in validNamespace) { val possibleNamespace = newTag.dropLast(1).lowercase().trim()
namespace = newTag.trim() tag = if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) {
namespace = possibleNamespace
"" ""
} else newTag } else newTag
selection = newTextValue.selection selection = newTextValue.selection
@@ -310,10 +364,10 @@ fun NewQueryChip(
opened = false opened = false
} }
HorizontalDivider() 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)) { NewQueryRow(modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.search_add_query_item_tag)) {
opened = false opened = false
onNewQuery(EditableSearchQueryState.Tag()) onNewQuery(EditableSearchQueryState.Tag(expanded = true))
} }
} }
if (currentQuery !is EditableSearchQueryState.And) { if (currentQuery !is EditableSearchQueryState.And) {
@@ -351,11 +405,14 @@ fun NewQueryChip(
fun QueryEditorQueryView( fun QueryEditorQueryView(
state: EditableSearchQueryState, state: EditableSearchQueryState,
onQueryRemove: (EditableSearchQueryState) -> Unit, onQueryRemove: (EditableSearchQueryState) -> Unit,
requestScrollTo: (Float) -> Unit,
requestScrollBy: (Float) -> Unit,
) { ) {
when (state) { when (state) {
is EditableSearchQueryState.Tag -> { is EditableSearchQueryState.Tag -> {
EditableTagChip( EditableTagChip(
state, state,
requestScrollTo = requestScrollTo,
rightIcon = { rightIcon = {
Icon( Icon(
modifier = Modifier modifier = Modifier
@@ -404,7 +461,9 @@ fun QueryEditorQueryView(
if (index != 0) { Text("+", modifier = Modifier.padding(horizontal = 8.dp)) } if (index != 0) { Text("+", modifier = Modifier.padding(horizontal = 8.dp)) }
QueryEditorQueryView( QueryEditorQueryView(
subQueryState, subQueryState,
onQueryRemove = { state.queries.remove(it) } onQueryRemove = { state.queries.remove(it) },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy
) )
} }
NewQueryChip(state) { newQueryState -> NewQueryChip(state) { newQueryState ->
@@ -429,6 +488,24 @@ fun QueryEditorQueryView(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start, 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -446,9 +523,27 @@ fun QueryEditorQueryView(
state.queries.forEach { subQuery -> state.queries.forEach { subQuery ->
QueryEditorQueryView( QueryEditorQueryView(
subQuery, 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 -> NewQueryChip(state) { newQueryState ->
state.queries.add(newQueryState) state.queries.add(newQueryState)
} }
@@ -491,7 +586,9 @@ fun QueryEditorQueryView(
if (subQueryStateSnapshot != null) { if (subQueryStateSnapshot != null) {
QueryEditorQueryView( QueryEditorQueryView(
subQueryStateSnapshot, subQueryStateSnapshot,
onQueryRemove = { subQueryState = null } onQueryRemove = { subQueryState = null },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy,
) )
} }
@@ -518,9 +615,19 @@ fun QueryEditor(
) { ) {
var rootQuery by state.query 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( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .onGloballyPositioned {
topY = it.positionInRoot().y
}
.verticalScroll(scrollState)
.padding(8.dp), .padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
@@ -528,7 +635,19 @@ fun QueryEditor(
if (rootQuerySnapshot != null) { if (rootQuerySnapshot != null) {
QueryEditorQueryView( QueryEditorQueryView(
state = rootQuerySnapshot, 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)
}
}
) )
} }