AdaptiveSheet.kt 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. package eu.kanade.presentation.components
  2. import androidx.activity.compose.BackHandler
  3. import androidx.compose.animation.core.animateFloatAsState
  4. import androidx.compose.animation.core.tween
  5. import androidx.compose.animation.fadeIn
  6. import androidx.compose.animation.fadeOut
  7. import androidx.compose.animation.with
  8. import androidx.compose.foundation.background
  9. import androidx.compose.foundation.clickable
  10. import androidx.compose.foundation.gestures.Orientation
  11. import androidx.compose.foundation.interaction.MutableInteractionSource
  12. import androidx.compose.foundation.layout.Box
  13. import androidx.compose.foundation.layout.BoxWithConstraints
  14. import androidx.compose.foundation.layout.PaddingValues
  15. import androidx.compose.foundation.layout.WindowInsets
  16. import androidx.compose.foundation.layout.WindowInsetsSides
  17. import androidx.compose.foundation.layout.asPaddingValues
  18. import androidx.compose.foundation.layout.consumeWindowInsets
  19. import androidx.compose.foundation.layout.fillMaxSize
  20. import androidx.compose.foundation.layout.navigationBars
  21. import androidx.compose.foundation.layout.offset
  22. import androidx.compose.foundation.layout.only
  23. import androidx.compose.foundation.layout.padding
  24. import androidx.compose.foundation.layout.requiredWidthIn
  25. import androidx.compose.foundation.layout.systemBars
  26. import androidx.compose.foundation.layout.systemBarsPadding
  27. import androidx.compose.foundation.layout.widthIn
  28. import androidx.compose.foundation.layout.windowInsetsPadding
  29. import androidx.compose.foundation.shape.ZeroCornerSize
  30. import androidx.compose.material.SwipeableState
  31. import androidx.compose.material.rememberSwipeableState
  32. import androidx.compose.material.swipeable
  33. import androidx.compose.material3.MaterialTheme
  34. import androidx.compose.material3.Surface
  35. import androidx.compose.runtime.Composable
  36. import androidx.compose.runtime.LaunchedEffect
  37. import androidx.compose.runtime.getValue
  38. import androidx.compose.runtime.mutableStateOf
  39. import androidx.compose.runtime.remember
  40. import androidx.compose.runtime.rememberCoroutineScope
  41. import androidx.compose.runtime.setValue
  42. import androidx.compose.runtime.snapshotFlow
  43. import androidx.compose.ui.Alignment
  44. import androidx.compose.ui.Modifier
  45. import androidx.compose.ui.draw.alpha
  46. import androidx.compose.ui.geometry.Offset
  47. import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
  48. import androidx.compose.ui.input.nestedscroll.NestedScrollSource
  49. import androidx.compose.ui.input.nestedscroll.nestedScroll
  50. import androidx.compose.ui.unit.Dp
  51. import androidx.compose.ui.unit.IntOffset
  52. import androidx.compose.ui.unit.Velocity
  53. import androidx.compose.ui.unit.dp
  54. import cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfiguration
  55. import cafe.adriel.voyager.navigator.Navigator
  56. import cafe.adriel.voyager.transitions.ScreenTransition
  57. import eu.kanade.core.navigation.Screen
  58. import eu.kanade.presentation.util.isTabletUi
  59. import kotlinx.coroutines.delay
  60. import kotlinx.coroutines.flow.collectLatest
  61. import kotlinx.coroutines.flow.drop
  62. import kotlinx.coroutines.flow.filter
  63. import kotlinx.coroutines.launch
  64. import kotlin.math.roundToInt
  65. import kotlin.time.Duration.Companion.milliseconds
  66. private const val SheetAnimationDuration = 500
  67. private val SheetAnimationSpec = tween<Float>(durationMillis = SheetAnimationDuration)
  68. private const val ScrimAnimationDuration = 350
  69. private val ScrimAnimationSpec = tween<Float>(durationMillis = ScrimAnimationDuration)
  70. @Composable
  71. fun NavigatorAdaptiveSheet(
  72. screen: Screen,
  73. tonalElevation: Dp = 1.dp,
  74. enableSwipeDismiss: (Navigator) -> Boolean = { true },
  75. onDismissRequest: () -> Unit,
  76. ) {
  77. Navigator(
  78. screen = screen,
  79. content = { sheetNavigator ->
  80. AdaptiveSheet(
  81. tonalElevation = tonalElevation,
  82. enableSwipeDismiss = enableSwipeDismiss(sheetNavigator),
  83. onDismissRequest = onDismissRequest,
  84. ) {
  85. ScreenTransition(
  86. navigator = sheetNavigator,
  87. transition = {
  88. fadeIn(animationSpec = tween(220, delayMillis = 90)) with
  89. fadeOut(animationSpec = tween(90))
  90. },
  91. )
  92. BackHandler(
  93. enabled = sheetNavigator.size > 1,
  94. onBack = sheetNavigator::pop,
  95. )
  96. }
  97. // Make sure screens are disposed no matter what
  98. if (sheetNavigator.parent?.disposeBehavior?.disposeNestedNavigators == false) {
  99. DisposableEffectIgnoringConfiguration {
  100. onDispose {
  101. sheetNavigator.items
  102. .asReversed()
  103. .forEach(sheetNavigator::dispose)
  104. }
  105. }
  106. }
  107. },
  108. )
  109. }
  110. /**
  111. * Sheet with adaptive position aligned to bottom on small screen, otherwise aligned to center
  112. * and will not be able to dismissed with swipe gesture.
  113. *
  114. * Max width of the content is set to 460 dp.
  115. */
  116. @Composable
  117. fun AdaptiveSheet(
  118. tonalElevation: Dp = 1.dp,
  119. enableSwipeDismiss: Boolean = true,
  120. onDismissRequest: () -> Unit,
  121. content: @Composable (PaddingValues) -> Unit,
  122. ) {
  123. val isTabletUi = isTabletUi()
  124. AdaptiveSheetImpl(
  125. isTabletUi = isTabletUi,
  126. tonalElevation = tonalElevation,
  127. enableSwipeDismiss = enableSwipeDismiss,
  128. onDismissRequest = onDismissRequest,
  129. ) {
  130. val contentPadding = if (isTabletUi) {
  131. PaddingValues()
  132. } else {
  133. WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
  134. }
  135. content(contentPadding)
  136. }
  137. }
  138. @Composable
  139. fun AdaptiveSheetImpl(
  140. isTabletUi: Boolean,
  141. tonalElevation: Dp,
  142. enableSwipeDismiss: Boolean,
  143. onDismissRequest: () -> Unit,
  144. content: @Composable () -> Unit,
  145. ) {
  146. val scope = rememberCoroutineScope()
  147. if (isTabletUi) {
  148. var targetAlpha by remember { mutableStateOf(0f) }
  149. val alpha by animateFloatAsState(
  150. targetValue = targetAlpha,
  151. animationSpec = ScrimAnimationSpec,
  152. )
  153. val internalOnDismissRequest: () -> Unit = {
  154. scope.launch {
  155. targetAlpha = 0f
  156. delay(ScrimAnimationSpec.durationMillis.milliseconds)
  157. onDismissRequest()
  158. }
  159. }
  160. BoxWithConstraints(
  161. modifier = Modifier
  162. .clickable(
  163. enabled = true,
  164. interactionSource = remember { MutableInteractionSource() },
  165. indication = null,
  166. onClick = internalOnDismissRequest,
  167. )
  168. .fillMaxSize()
  169. .alpha(alpha),
  170. contentAlignment = Alignment.Center,
  171. ) {
  172. Box(
  173. modifier = Modifier
  174. .matchParentSize()
  175. .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),
  176. )
  177. Surface(
  178. modifier = Modifier
  179. .requiredWidthIn(max = 460.dp)
  180. .clickable(
  181. interactionSource = remember { MutableInteractionSource() },
  182. indication = null,
  183. onClick = {},
  184. )
  185. .systemBarsPadding()
  186. .padding(vertical = 16.dp),
  187. shape = MaterialTheme.shapes.extraLarge,
  188. tonalElevation = tonalElevation,
  189. content = {
  190. BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest)
  191. content()
  192. },
  193. )
  194. LaunchedEffect(Unit) {
  195. targetAlpha = 1f
  196. }
  197. }
  198. } else {
  199. val swipeState = rememberSwipeableState(
  200. initialValue = 1,
  201. animationSpec = SheetAnimationSpec,
  202. )
  203. val internalOnDismissRequest: () -> Unit = { if (swipeState.currentValue == 0) scope.launch { swipeState.animateTo(1) } }
  204. BoxWithConstraints(
  205. modifier = Modifier
  206. .clickable(
  207. interactionSource = remember { MutableInteractionSource() },
  208. indication = null,
  209. onClick = internalOnDismissRequest,
  210. )
  211. .fillMaxSize(),
  212. contentAlignment = Alignment.BottomCenter,
  213. ) {
  214. val fullHeight = constraints.maxHeight.toFloat()
  215. val anchors = mapOf(0f to 0, fullHeight to 1)
  216. val scrimAlpha by animateFloatAsState(
  217. targetValue = if (swipeState.targetValue == 1) 0f else 1f,
  218. animationSpec = ScrimAnimationSpec,
  219. )
  220. Box(
  221. modifier = Modifier
  222. .matchParentSize()
  223. .alpha(scrimAlpha)
  224. .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),
  225. )
  226. Surface(
  227. modifier = Modifier
  228. .widthIn(max = 460.dp)
  229. .clickable(
  230. interactionSource = remember { MutableInteractionSource() },
  231. indication = null,
  232. onClick = {},
  233. )
  234. .nestedScroll(
  235. remember(enableSwipeDismiss, anchors) {
  236. swipeState.preUpPostDownNestedScrollConnection(
  237. enabled = enableSwipeDismiss,
  238. anchor = anchors,
  239. )
  240. },
  241. )
  242. .offset {
  243. IntOffset(
  244. 0,
  245. swipeState.offset.value.roundToInt(),
  246. )
  247. }
  248. .swipeable(
  249. enabled = enableSwipeDismiss,
  250. state = swipeState,
  251. anchors = anchors,
  252. orientation = Orientation.Vertical,
  253. resistance = null,
  254. )
  255. .windowInsetsPadding(
  256. WindowInsets.systemBars
  257. .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
  258. )
  259. .consumeWindowInsets(
  260. WindowInsets.systemBars
  261. .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
  262. ),
  263. shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = ZeroCornerSize, bottomEnd = ZeroCornerSize),
  264. tonalElevation = tonalElevation,
  265. content = {
  266. BackHandler(enabled = swipeState.targetValue == 0, onBack = internalOnDismissRequest)
  267. content()
  268. },
  269. )
  270. LaunchedEffect(swipeState) {
  271. scope.launch { swipeState.animateTo(0) }
  272. snapshotFlow { swipeState.currentValue }
  273. .drop(1)
  274. .filter { it == 1 }
  275. .collectLatest {
  276. delay(ScrimAnimationSpec.durationMillis.milliseconds)
  277. onDismissRequest()
  278. }
  279. }
  280. }
  281. }
  282. }
  283. /**
  284. * Yoinked from Swipeable.kt with modifications to disable
  285. */
  286. private fun <T> SwipeableState<T>.preUpPostDownNestedScrollConnection(
  287. enabled: Boolean = true,
  288. anchor: Map<Float, T>,
  289. ) = object : NestedScrollConnection {
  290. override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
  291. val delta = available.toFloat()
  292. return if (enabled && delta < 0 && source == NestedScrollSource.Drag) {
  293. performDrag(delta).toOffset()
  294. } else {
  295. Offset.Zero
  296. }
  297. }
  298. override fun onPostScroll(
  299. consumed: Offset,
  300. available: Offset,
  301. source: NestedScrollSource,
  302. ): Offset {
  303. return if (enabled && source == NestedScrollSource.Drag) {
  304. performDrag(available.toFloat()).toOffset()
  305. } else {
  306. Offset.Zero
  307. }
  308. }
  309. override suspend fun onPreFling(available: Velocity): Velocity {
  310. val toFling = Offset(available.x, available.y).toFloat()
  311. return if (enabled && toFling < 0 && offset.value > anchor.keys.minOrNull()!!) {
  312. performFling(velocity = toFling)
  313. // since we go to the anchor with tween settling, consume all for the best UX
  314. available
  315. } else {
  316. Velocity.Zero
  317. }
  318. }
  319. override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
  320. return if (enabled) {
  321. performFling(velocity = Offset(available.x, available.y).toFloat())
  322. available
  323. } else {
  324. Velocity.Zero
  325. }
  326. }
  327. private fun Float.toOffset(): Offset = Offset(0f, this)
  328. private fun Offset.toFloat(): Float = this.y
  329. }