Reinventing the wheel, but better this time. Better approach for draggable history list in calculator:

- Using anchored draggable
- Remembering button sizes
- Additional buttons are not resizeable anymore

closes #75
This commit is contained in:
sadellie 2023-07-30 20:39:54 +03:00
parent 19f1f436c3
commit 9cfb35b03e
5 changed files with 231 additions and 256 deletions

View File

@ -20,18 +20,16 @@ package com.sadellie.unitto.feature.calculator
import android.content.res.Configuration
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.RoundedCornerShape
@ -45,18 +43,17 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
@ -73,13 +70,9 @@ import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField
import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField
import com.sadellie.unitto.data.model.HistoryItem
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard
import com.sadellie.unitto.feature.calculator.components.DragDownView
import com.sadellie.unitto.feature.calculator.components.HistoryList
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
internal fun CalculatorRoute(
@ -117,24 +110,15 @@ private fun CalculatorScreen(
clearHistory: () -> Unit
) {
val focusManager = LocalFocusManager.current
val dragAmount = remember { Animatable(0f) }
val dragCoroutineScope = rememberCoroutineScope()
val dragAnimSpec = rememberSplineBasedDecay<Float>()
var textThingyHeight by remember { mutableIntStateOf(0) }
var historyItemHeight by remember { mutableIntStateOf(0) }
var showClearHistoryDialog by rememberSaveable { mutableStateOf(false) }
val showClearHistoryButton by remember(dragAmount.value, historyItemHeight) {
derivedStateOf { dragAmount.value > historyItemHeight }
}
var showClearHistoryButton by rememberSaveable { mutableStateOf(false) }
UnittoScreenWithTopBar(
title = { Text(stringResource(R.string.calculator)) },
navigationIcon = { MenuButton { navigateToMenu() } },
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceVariant),
actions = {
Crossfade(showClearHistoryButton) {
Crossfade(showClearHistoryButton, label = "Clear button reveal") {
if (it) {
IconButton(
onClick = { showClearHistoryDialog = true },
@ -151,26 +135,57 @@ private fun CalculatorScreen(
}
}
) { paddingValues ->
DragDownView(
BoxWithConstraints(
modifier = Modifier.padding(paddingValues),
drag = dragAmount.value.roundToInt().coerceAtLeast(0),
historyItemHeight = historyItemHeight,
historyList = {
) {
val density = LocalDensity.current
var historyItemHeight by remember { mutableStateOf(0.dp) }
val textBoxHeight = maxHeight * 0.25f
var dragStateCurrentValue by rememberSaveable { mutableStateOf(DragState.CLOSED) }
val dragState = rememberDragState(
historyItem = historyItemHeight,
max = maxHeight - textBoxHeight,
initialValue = dragStateCurrentValue
)
val dragDp by remember(dragState.requireOffset()) {
derivedStateOf {
focusManager.clearFocus(true)
with(density) { dragState.requireOffset().toDp() }
}
}
val keyboardHeight by remember(dragState.requireOffset()) {
derivedStateOf {
if (dragDp > historyItemHeight) {
maxHeight - textBoxHeight - historyItemHeight
} else {
maxHeight - textBoxHeight - dragDp
}
}
}
LaunchedEffect(dragState.currentValue) {
dragStateCurrentValue = dragState.currentValue
showClearHistoryButton = dragState.currentValue == DragState.OPEN
}
// History
HistoryList(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.fillMaxSize(),
.fillMaxWidth()
.height(dragDp),
historyItems = uiState.history,
historyItemHeightCallback = { historyItemHeight = it },
heightCallback = { historyItemHeight = it },
formatterSymbols = uiState.formatterSymbols,
addTokens = addSymbol,
)
},
textFields = { maxDragAmount ->
// Input
Column(
Modifier
.fillMaxHeight(0.25f)
.onPlaced { textThingyHeight = it.size.height }
.offset(y = dragDp)
.height(textBoxHeight)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(
@ -178,29 +193,9 @@ private fun CalculatorScreen(
bottomStartPercent = 20, bottomEndPercent = 20
)
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
dragCoroutineScope.launch {
val draggedAmount = (dragAmount.value + delta).coerceAtLeast(0f)
dragAmount.snapTo(draggedAmount)
}
},
onDragStarted = {
// Moving composables with focus causes performance drop
focusManager.clearFocus(true)
},
onDragStopped = { velocity ->
dragCoroutineScope.launch {
dragAmount.animateDecay(velocity, dragAnimSpec)
// Snap to closest anchor (0, one history item, all history items)
val draggedAmount = listOf(0, historyItemHeight, maxDragAmount)
.minBy { abs(dragAmount.value.roundToInt() - it) }
.toFloat()
dragAmount.animateTo(draggedAmount)
}
}
.anchoredDraggable(
state = dragState,
orientation = Orientation.Vertical
)
.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@ -268,11 +263,13 @@ private fun CalculatorScreen(
.sizeIn(24.dp, 4.dp)
)
}
},
numPad = {
// Keyboard
CalculatorKeyboard(
modifier = Modifier
.fillMaxSize()
.offset(y = dragDp + textBoxHeight)
.height(keyboardHeight)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
radianMode = uiState.radianMode,
fractional = uiState.formatterSymbols.fractional,
@ -284,7 +281,6 @@ private fun CalculatorScreen(
evaluate = evaluate
)
}
)
}
if (showClearHistoryDialog) {

View File

@ -0,0 +1,53 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.feature.calculator
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
internal enum class DragState { CLOSED, HALF, OPEN }
@Composable
internal fun rememberDragState(
initialValue: DragState = DragState.CLOSED,
historyItem: Dp,
max: Dp,
): AnchoredDraggableState<DragState> {
val historyItemHeight = with(LocalDensity.current) { historyItem.toPx() }
val maxHeight = with(LocalDensity.current) { max.toPx() }
return remember(key1 = historyItem) {
AnchoredDraggableState(
initialValue = initialValue,
anchors = DraggableAnchors {
DragState.CLOSED at 0f
DragState.HALF at historyItemHeight
DragState.OPEN at maxHeight
},
positionalThreshold = { 0f },
velocityThreshold = { 0f },
animationSpec = tween()
)
}
}

View File

@ -41,6 +41,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -50,7 +51,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import com.sadellie.unitto.core.base.Token
import com.sadellie.unitto.core.ui.common.ColumnWithConstraints
import com.sadellie.unitto.core.ui.common.KeyboardButtonAdditional
@ -160,23 +160,36 @@ private fun PortraitKeyboard(
ColumnWithConstraints(
modifier = modifier
) { constraints ->
fun verticalFraction(fraction: Float): Dp = constraints.maxHeight * fraction
fun horizontalFraction(fraction: Float): Dp = constraints.maxWidth * fraction
val mainButtonHorizontalPadding by remember {
derivedStateOf { (constraints.maxWidth * 0.01f) }
}
val additionalButtonHeight by remember {
mutableStateOf(constraints.maxHeight * 0.09f)
}
val spacerHeight by remember {
mutableStateOf(constraints.maxHeight * 0.025f)
}
val additionalRowSpacedBy by remember {
mutableStateOf(constraints.maxWidth * 0.03f)
}
val weightModifier = Modifier.weight(1f)
val mainButtonModifier = Modifier
.fillMaxSize()
.weight(1f)
.padding(horizontalFraction(0.015f), verticalFraction(0.009f))
.padding(mainButtonHorizontalPadding)
val additionalButtonModifier = Modifier
.weight(1f)
.height(verticalFraction(0.09f))
.height(additionalButtonHeight)
Spacer(modifier = Modifier.height(verticalFraction(0.025f)))
Spacer(modifier = Modifier.height(spacerHeight))
Row(
modifier = Modifier,
horizontalArrangement = Arrangement.spacedBy(horizontalFraction(0.03f))
horizontalArrangement = Arrangement.spacedBy(additionalRowSpacedBy)
) {
// Additional buttons
Crossfade(invMode, weightModifier) {
@ -204,7 +217,7 @@ private fun PortraitKeyboard(
}
Box(
modifier = Modifier.size(verticalFraction(0.09f)),
modifier = Modifier.size(additionalButtonHeight),
contentAlignment = Alignment.Center
) {
// Expand/Collapse
@ -217,7 +230,7 @@ private fun PortraitKeyboard(
}
}
Spacer(modifier = Modifier.height(verticalFraction(0.025f)))
Spacer(modifier = Modifier.height(spacerHeight))
Row(weightModifier) {
KeyboardButtonFilled(mainButtonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.Operator.leftBracket) }
@ -250,7 +263,7 @@ private fun PortraitKeyboard(
KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Equal, allowVibration) { evaluate() }
}
Spacer(modifier = Modifier.height(verticalFraction(0.015f)))
Spacer(modifier = Modifier.height(spacerHeight))
}
}

View File

@ -1,92 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.feature.calculator.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.offset
/**
* Screen layout where [historyList] can be seen only when you drag [textFields] down.
*
* @param modifier [Modifier] that will be applied to [SubcomposeLayout].
* @param drag Drag amount. Update this when dragging [textFields].
* @param historyItemHeight Height of one item in [historyList].
* @param textFields This part of the UI should be used as a handle. Offsets when dragging.
* @param numPad Composable with buttons. Offsets when drag amount is higher than [historyItemHeight]
* (otherwise will just shrink).
*/
@Composable
internal fun DragDownView(
modifier: Modifier,
drag: Int,
historyItemHeight: Int,
historyList: @Composable () -> Unit,
textFields: @Composable (maxDragAmount: Int) -> Unit,
numPad: @Composable () -> Unit
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val showHistory = drag < historyItemHeight
val offset = if (showHistory) (drag - historyItemHeight).coerceAtLeast(0) else 0
val textFieldPlaceables = subcompose(DragDownContent.TextFields) {
textFields(constraints.maxHeight)
}.map { it.measure(constraints.offset(offset)) }
val textFieldsHeight = textFieldPlaceables.maxByOrNull { it.height }?.height ?: 0
val historyListPlaceables = subcompose(DragDownContent.HistoryList) {
historyList()
}.map {
it.measure(
constraints.copy(
maxHeight = drag.coerceAtMost(constraints.maxHeight - textFieldsHeight)
)
)
}
val padding = if (showHistory) drag.coerceAtLeast(0) else historyItemHeight
val numPadConstraints = constraints
.offset(offset)
.copy(maxHeight = constraints.maxHeight - textFieldsHeight - padding)
val numPadPlaceables = subcompose(DragDownContent.NumPad, numPad).map {
it.measure(numPadConstraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
var yPos = 0
historyListPlaceables.forEach {
it.place(0, yPos)
yPos += it.height
}
textFieldPlaceables.forEach {
it.place(0, yPos)
yPos += it.height
}
numPadPlaceables.forEach {
it.place(0, yPos)
yPos += it.height
}
}
}
}
private enum class DragDownContent { HistoryList, TextFields, NumPad }

View File

@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalTextInputService
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView
@ -57,6 +58,7 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.sadellie.unitto.core.base.R
import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer
@ -73,14 +75,14 @@ import java.util.Locale
internal fun HistoryList(
modifier: Modifier,
historyItems: List<HistoryItem>,
historyItemHeightCallback: (Int) -> Unit,
heightCallback: (Dp) -> Unit,
formatterSymbols: FormatterSymbols,
addTokens: (String) -> Unit,
) {
if (historyItems.isEmpty()) {
HistoryListPlaceholder(
modifier = modifier,
historyItemHeightCallback = historyItemHeightCallback
heightCallback = heightCallback
)
} else {
HistoryListContent(
@ -88,7 +90,7 @@ internal fun HistoryList(
historyItems = historyItems,
addTokens = addTokens,
formatterSymbols = formatterSymbols,
historyItemHeightCallback = historyItemHeightCallback
heightCallback = heightCallback
)
}
}
@ -96,15 +98,17 @@ internal fun HistoryList(
@Composable
private fun HistoryListPlaceholder(
modifier: Modifier,
historyItemHeightCallback: (Int) -> Unit
heightCallback: (Dp) -> Unit,
) {
val density = LocalDensity.current
Column(
modifier = modifier.wrapContentHeight(unbounded = true),
verticalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier
.onPlaced { historyItemHeightCallback(it.size.height) }
.onPlaced { heightCallback(with(density) { it.size.height.toDp() }) }
.fillMaxWidth()
.padding(vertical = 32.dp),
verticalArrangement = Arrangement.Center,
@ -122,8 +126,9 @@ private fun HistoryListContent(
historyItems: List<HistoryItem>,
addTokens: (String) -> Unit,
formatterSymbols: FormatterSymbols,
historyItemHeightCallback: (Int) -> Unit
heightCallback: (Dp) -> Unit,
) {
val density = LocalDensity.current
val state = rememberLazyListState()
val firstItem by remember(historyItems) { mutableStateOf(historyItems.first()) }
val restOfTheItems by remember(firstItem) { mutableStateOf(historyItems.drop(1)) }
@ -139,7 +144,7 @@ private fun HistoryListContent(
// We do this so that callback for items height is called only once
item(firstItem.id) {
HistoryListItem(
modifier = Modifier.onPlaced { historyItemHeightCallback(it.size.height) },
modifier = Modifier.onPlaced { heightCallback(with(density) { it.size.height.toDp() }) },
historyItem = historyItems.first(),
formatterSymbols = formatterSymbols,
addTokens = addTokens,
@ -270,7 +275,7 @@ private fun PreviewHistoryList() {
.fillMaxSize(),
historyItems = historyItems,
formatterSymbols = FormatterSymbols.Spaces,
historyItemHeightCallback = {},
heightCallback = {},
addTokens = {}
)
}