multiplatform: FWheelPicker dependency from source (#2659)

* multiplatform: FWheelPicker dependency from source

* EOLs

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2023-07-05 15:47:39 +03:00
committed by GitHub
parent 313d3a732d
commit 7fea9c85bd
4 changed files with 641 additions and 4 deletions

View File

@@ -96,10 +96,6 @@ kotlin {
api("androidx.camera:camera-lifecycle:${camerax_version}")
api("androidx.camera:camera-view:${camerax_version}")
// LALAL REPLACE IT WITH SOURCE
// Wheel picker
api("com.github.zj565061763:compose-wheel-picker:1.0.0-alpha10")
// LALAL REMOVE
api("org.jsoup:jsoup:1.13.1")
api("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0")

View File

@@ -0,0 +1,314 @@
package com.sd.lib.compose.wheel_picker
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.absoluteValue
interface FWheelPickerContentScope {
val state: FWheelPickerState
}
interface FWheelPickerContentWrapperScope {
val state: FWheelPickerState
@Composable
fun content(index: Int)
}
// LALAL: make separate repo for this
@Composable
fun FVerticalWheelPicker(
count: Int,
state: FWheelPickerState = rememberFWheelPickerState(),
modifier: Modifier = Modifier,
key: ((index: Int) -> Any)? = null,
itemHeight: Dp = 35.dp,
unfocusedCount: Int = 1,
userScrollEnabled: Boolean = true,
reverseLayout: Boolean = false,
debug: Boolean = false,
focus: @Composable () -> Unit = { FWheelPickerFocusVertical() },
contentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit = DefaultWheelPickerContentWrapper,
content: @Composable FWheelPickerContentScope.(index: Int) -> Unit,
) {
WheelPicker(
isVertical = true,
count = count,
state = state,
modifier = modifier,
key = key,
itemSize = itemHeight,
unfocusedCount = unfocusedCount,
userScrollEnabled = userScrollEnabled,
reverseLayout = reverseLayout,
debug = debug,
focus = focus,
contentWrapper = contentWrapper,
content = content,
)
}
@Composable
fun FHorizontalWheelPicker(
count: Int,
state: FWheelPickerState = rememberFWheelPickerState(),
modifier: Modifier = Modifier,
key: ((index: Int) -> Any)? = null,
itemWidth: Dp = 35.dp,
unfocusedCount: Int = 1,
userScrollEnabled: Boolean = true,
reverseLayout: Boolean = false,
debug: Boolean = false,
focus: @Composable () -> Unit = { FWheelPickerFocusHorizontal() },
contentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit = DefaultWheelPickerContentWrapper,
content: @Composable FWheelPickerContentScope.(index: Int) -> Unit,
) {
WheelPicker(
isVertical = false,
count = count,
state = state,
modifier = modifier,
key = key,
itemSize = itemWidth,
unfocusedCount = unfocusedCount,
userScrollEnabled = userScrollEnabled,
reverseLayout = reverseLayout,
debug = debug,
focus = focus,
contentWrapper = contentWrapper,
content = content,
)
}
@Composable
private fun WheelPicker(
isVertical: Boolean,
count: Int,
state: FWheelPickerState,
modifier: Modifier,
key: ((index: Int) -> Any)?,
itemSize: Dp,
unfocusedCount: Int,
userScrollEnabled: Boolean,
reverseLayout: Boolean,
debug: Boolean,
focus: @Composable () -> Unit,
contentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit,
content: @Composable FWheelPickerContentScope.(index: Int) -> Unit,
) {
require(count >= 0) { "require count >= 0" }
require(unfocusedCount >= 1) { "require unfocusedCount >= 1" }
state.debug = debug
LaunchedEffect(state, count) {
state.notifyCountChanged(count)
}
val nestedScrollConnection = remember(state) {
WheelPickerNestedScrollConnection(state)
}.apply {
this.isVertical = isVertical
this.itemSizePx = with(LocalDensity.current) { itemSize.roundToPx() }
this.reverseLayout = reverseLayout
}
val totalSize = remember(itemSize, unfocusedCount) {
itemSize * (unfocusedCount * 2 + 1)
}
val contentWrapperScope = remember(state) {
val contentScope = WheelPickerContentScopeImpl(state)
FWheelPickerContentWrapperScopeImpl(contentScope)
}.apply {
this.content = content
}
Box(
modifier = modifier
.nestedScroll(nestedScrollConnection)
.run {
if (totalSize > 0.dp) {
if (isVertical) {
height(totalSize).widthIn(40.dp)
} else {
width(totalSize).heightIn(40.dp)
}
} else {
this
}
},
contentAlignment = Alignment.Center,
) {
val lazyListScope: LazyListScope.() -> Unit = {
repeat(unfocusedCount) {
item {
ItemSizeBox(
isVertical = isVertical,
itemSize = itemSize,
)
}
}
items(
count = count,
key = key,
) { index ->
ItemSizeBox(
isVertical = isVertical,
itemSize = itemSize,
) {
contentWrapperScope.contentWrapper(index)
}
}
repeat(unfocusedCount) {
item {
ItemSizeBox(
isVertical = isVertical,
itemSize = itemSize,
)
}
}
}
if (isVertical) {
LazyColumn(
state = state.lazyListState,
horizontalAlignment = Alignment.CenterHorizontally,
reverseLayout = reverseLayout,
userScrollEnabled = userScrollEnabled,
modifier = Modifier.matchParentSize(),
content = lazyListScope,
)
} else {
LazyRow(
state = state.lazyListState,
verticalAlignment = Alignment.CenterVertically,
reverseLayout = reverseLayout,
userScrollEnabled = userScrollEnabled,
modifier = Modifier.matchParentSize(),
content = lazyListScope,
)
}
ItemSizeBox(
modifier = Modifier.align(Alignment.Center),
isVertical = isVertical,
itemSize = itemSize,
) {
focus()
}
}
}
@Composable
private fun ItemSizeBox(
modifier: Modifier = Modifier,
isVertical: Boolean,
itemSize: Dp,
content: @Composable () -> Unit = { },
) {
Box(
modifier
.run {
if (isVertical) {
height(itemSize)
} else {
width(itemSize)
}
},
contentAlignment = Alignment.Center,
) {
content()
}
}
private class WheelPickerNestedScrollConnection(
private val state: FWheelPickerState,
) : NestedScrollConnection {
var isVertical: Boolean? = null
var itemSizePx: Int? = null
var reverseLayout: Boolean? = null
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
state.synchronizeCurrentIndexSnapshot()
return super.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val currentIndex = state.synchronizeCurrentIndexSnapshot()
return if (currentIndex >= 0) {
val flingItemCount = available.flingItemCount(
isVertical = isVertical!!,
itemSize = itemSizePx!!,
decay = exponentialDecay(2f),
reverseLayout = reverseLayout!!,
)
if (flingItemCount.absoluteValue > 0) {
state.animateScrollToIndex(currentIndex - flingItemCount)
} else {
state.animateScrollToIndex(currentIndex)
}
available
} else {
super.onPreFling(available)
}
}
}
private fun Velocity.flingItemCount(
isVertical: Boolean,
itemSize: Int,
decay: DecayAnimationSpec<Float>,
reverseLayout: Boolean,
): Int {
if (itemSize <= 0) return 0
val velocity = if (isVertical) y else x
val targetValue = decay.calculateTargetValue(0f, velocity)
val flingItemCount = (targetValue / itemSize).toInt()
return if (reverseLayout) -flingItemCount else flingItemCount
}
private class WheelPickerContentScopeImpl(
override val state: FWheelPickerState,
) : FWheelPickerContentScope
private class FWheelPickerContentWrapperScopeImpl(
private val contentScope: FWheelPickerContentScope
) : FWheelPickerContentWrapperScope {
lateinit var content: @Composable FWheelPickerContentScope.(index: Int) -> Unit
override val state: FWheelPickerState get() = contentScope.state
@Composable
override fun content(index: Int) {
contentScope.content(index)
}
}
internal inline fun logMsg(debug: Boolean, block: () -> String) {
if (debug) {
println("FWheelPicker" + block())
}
}

View File

@@ -0,0 +1,104 @@
package com.sd.lib.compose.wheel_picker
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The default implementation of focus view in vertical.
*/
@Composable
fun FWheelPickerFocusVertical(
modifier: Modifier = Modifier,
dividerSize: Dp = 1.dp,
dividerColor: Color = DefaultDividerColor,
) {
Box(
modifier = modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.background(dividerColor)
.height(dividerSize)
.fillMaxWidth()
.align(Alignment.TopCenter),
)
Box(
modifier = Modifier
.background(dividerColor)
.height(dividerSize)
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
}
}
/**
* The default implementation of focus view in horizontal.
*/
@Composable
fun FWheelPickerFocusHorizontal(
modifier: Modifier = Modifier,
dividerSize: Dp = 1.dp,
dividerColor: Color = DefaultDividerColor,
) {
Box(
modifier = modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.background(dividerColor)
.width(dividerSize)
.fillMaxHeight()
.align(Alignment.CenterStart),
)
Box(
modifier = Modifier
.background(dividerColor)
.width(dividerSize)
.fillMaxHeight()
.align(Alignment.CenterEnd),
)
}
}
/**
* Default divider color.
*/
private val DefaultDividerColor: Color
@Composable
get() = (if (isSystemInDarkTheme()) {
Color.White
} else {
Color.Black
}).copy(alpha = 0.2f)
/**
* Default content wrapper.
*/
val DefaultWheelPickerContentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit
get() = { index ->
val isFocus = index == state.currentIndexSnapshot
val targetAlpha = if (isFocus) 1.0f else 0.3f
val targetScale = if (isFocus) 1.0f else 0.8f
val animateScale by animateFloatAsState(targetScale)
Box(
modifier = Modifier
.graphicsLayer {
this.alpha = targetAlpha
this.scaleX = animateScale
this.scaleY = animateScale
}
) {
content(index)
}
}

View File

@@ -0,0 +1,223 @@
package com.sd.lib.compose.wheel_picker
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.math.absoluteValue
@Composable
fun rememberFWheelPickerState(
initialIndex: Int = 0,
): FWheelPickerState = rememberSaveable(saver = FWheelPickerState.Saver) {
FWheelPickerState(
initialIndex = initialIndex,
)
}
class FWheelPickerState(
initialIndex: Int = 0,
) : ScrollableState {
internal var debug = false
internal val lazyListState = LazyListState()
private var _currentIndex by mutableStateOf(-1)
private var _currentIndexSnapshot by mutableStateOf(-1)
private var _pendingIndex: Int? = initialIndex
set(value) {
field = value
if (value == null) resumeAwaitScroll()
}
private var _pendingIndexContinuation: Continuation<Unit>? = null
private var _count = 0
set(value) {
field = value.coerceAtLeast(0)
}
/**
* Index of picker when it is idle.
*
* Note that this property is observable and if you use it in the composable function
* it will be recomposed on every change.
*/
val currentIndex: Int
get() = _currentIndex
/**
* Index of picker when it is idle or drag but not fling.
*
* Note that this property is observable and if you use it in the composable function
* it will be recomposed on every change.
*/
val currentIndexSnapshot: Int
get() = _currentIndexSnapshot
val interactionSource: InteractionSource
get() = lazyListState.interactionSource
val hasPendingScroll: Boolean
get() = _pendingIndex != null
suspend fun animateScrollToIndex(
index: Int,
) {
logMsg(debug) { "animateScrollToIndex index:$index" }
val safeIndex = index.coerceAtLeast(0)
lazyListState.animateScrollToItem(safeIndex)
synchronizeCurrentIndex()
}
suspend fun scrollToIndex(
index: Int,
pending: Boolean = true,
) {
logMsg(debug) { "scrollToIndex index:$index pending:$pending" }
val safeIndex = index.coerceAtLeast(0)
lazyListState.scrollToItem(safeIndex)
synchronizeCurrentIndex()
if (pending) {
awaitScroll(safeIndex)
}
}
private suspend fun awaitScroll(index: Int) {
if (_currentIndex == index) return
logMsg(debug) { "awaitScroll index $index start" }
// Resume last continuation before suspend.
resumeAwaitScroll()
_pendingIndex = index
suspendCancellableCoroutine {
_pendingIndexContinuation = it
it.invokeOnCancellation {
logMsg(debug) { "awaitScroll index $index canceled" }
_pendingIndexContinuation = null
_pendingIndex = null
}
}
logMsg(debug) { "awaitScroll index $index finish" }
}
private fun resumeAwaitScroll() {
_pendingIndexContinuation?.let {
logMsg(debug) { "resumeAwaitScroll pendingIndex:$_pendingIndex" }
it.resume(Unit)
_pendingIndexContinuation = null
}
}
internal suspend fun notifyCountChanged(count: Int) {
logMsg(debug) { "notifyCountChanged count:$count currentIndex:$_currentIndex pendingIndex:$_pendingIndex" }
_count = count
val maxIndex = count - 1
if (_currentIndex > maxIndex) {
setCurrentIndexInternal(maxIndex)
} else {
_pendingIndex?.let { pendingIndex ->
if (count > pendingIndex) {
scrollToIndex(pendingIndex, pending = false)
}
}
if (_currentIndex < 0) {
synchronizeCurrentIndex()
}
}
}
private fun synchronizeCurrentIndex() {
logMsg(debug) { "synchronizeCurrentIndex" }
val index = synchronizeCurrentIndexSnapshot()
setCurrentIndexInternal(index)
}
private fun setCurrentIndexInternal(index: Int) {
val safeIndex = index.coerceAtLeast(-1)
if (_currentIndex != safeIndex) {
logMsg(debug) { "Current index changed $safeIndex" }
_currentIndex = safeIndex
_currentIndexSnapshot = safeIndex
if (_pendingIndex == safeIndex) {
_pendingIndex = null
}
}
}
internal fun synchronizeCurrentIndexSnapshot(): Int {
return (mostStartItemInfo()?.index ?: -1).also {
_currentIndexSnapshot = it
}
}
/**
* The item closest to the viewport start.
*/
private fun mostStartItemInfo(): LazyListItemInfo? {
if (_count <= 0) return null
val layoutInfo = lazyListState.layoutInfo
val listInfo = layoutInfo.visibleItemsInfo
if (listInfo.isEmpty()) return null
if (listInfo.size == 1) return listInfo.first()
val firstItem = listInfo.first()
val firstOffsetDelta = (firstItem.offset - layoutInfo.viewportStartOffset).absoluteValue
return if (firstOffsetDelta < firstItem.size / 2) {
firstItem
} else {
listInfo[1]
}
}
override val isScrollInProgress: Boolean
get() = lazyListState.isScrollInProgress
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit,
) {
lazyListState.scroll(scrollPriority, block)
}
override fun dispatchRawDelta(delta: Float): Float {
return lazyListState.dispatchRawDelta(delta)
}
companion object {
val Saver: Saver<FWheelPickerState, *> = listSaver(
save = {
listOf<Any>(
it.currentIndex,
)
},
restore = {
FWheelPickerState(
initialIndex = it[0] as Int,
)
}
)
}
}