Navigator.kt 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. package eu.kanade.presentation.util
  2. import android.annotation.SuppressLint
  3. import androidx.activity.BackEventCompat
  4. import androidx.activity.compose.PredictiveBackHandler
  5. import androidx.compose.animation.AnimatedContent
  6. import androidx.compose.animation.AnimatedContentTransitionScope
  7. import androidx.compose.animation.ContentTransform
  8. import androidx.compose.animation.EnterTransition
  9. import androidx.compose.animation.ExitTransition
  10. import androidx.compose.animation.core.FastOutSlowInEasing
  11. import androidx.compose.animation.core.LinearOutSlowInEasing
  12. import androidx.compose.animation.core.animate
  13. import androidx.compose.animation.core.tween
  14. import androidx.compose.animation.togetherWith
  15. import androidx.compose.foundation.layout.Box
  16. import androidx.compose.foundation.shape.RoundedCornerShape
  17. import androidx.compose.runtime.Composable
  18. import androidx.compose.runtime.LaunchedEffect
  19. import androidx.compose.runtime.ProvidableCompositionLocal
  20. import androidx.compose.runtime.Stable
  21. import androidx.compose.runtime.derivedStateOf
  22. import androidx.compose.runtime.getValue
  23. import androidx.compose.runtime.movableContentOf
  24. import androidx.compose.runtime.mutableFloatStateOf
  25. import androidx.compose.runtime.mutableIntStateOf
  26. import androidx.compose.runtime.mutableStateOf
  27. import androidx.compose.runtime.remember
  28. import androidx.compose.runtime.rememberCoroutineScope
  29. import androidx.compose.runtime.setValue
  30. import androidx.compose.runtime.staticCompositionLocalOf
  31. import androidx.compose.ui.Modifier
  32. import androidx.compose.ui.draw.drawWithCache
  33. import androidx.compose.ui.geometry.Offset
  34. import androidx.compose.ui.geometry.Rect
  35. import androidx.compose.ui.geometry.Size
  36. import androidx.compose.ui.graphics.BlurEffect
  37. import androidx.compose.ui.graphics.ColorFilter
  38. import androidx.compose.ui.graphics.ColorMatrix
  39. import androidx.compose.ui.graphics.Paint
  40. import androidx.compose.ui.graphics.RectangleShape
  41. import androidx.compose.ui.graphics.TransformOrigin
  42. import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
  43. import androidx.compose.ui.graphics.graphicsLayer
  44. import androidx.compose.ui.input.pointer.pointerInput
  45. import androidx.compose.ui.layout.onSizeChanged
  46. import androidx.compose.ui.platform.LocalView
  47. import androidx.compose.ui.unit.dp
  48. import androidx.compose.ui.unit.toSize
  49. import androidx.compose.ui.util.lerp
  50. import androidx.compose.ui.zIndex
  51. import cafe.adriel.voyager.core.model.ScreenModel
  52. import cafe.adriel.voyager.core.model.ScreenModelStore
  53. import cafe.adriel.voyager.core.screen.Screen
  54. import cafe.adriel.voyager.core.screen.ScreenKey
  55. import cafe.adriel.voyager.core.screen.uniqueScreenKey
  56. import cafe.adriel.voyager.core.stack.StackEvent
  57. import cafe.adriel.voyager.navigator.Navigator
  58. import cafe.adriel.voyager.transitions.ScreenTransitionContent
  59. import eu.kanade.tachiyomi.util.view.getWindowRadius
  60. import kotlinx.coroutines.CoroutineName
  61. import kotlinx.coroutines.CoroutineScope
  62. import kotlinx.coroutines.Dispatchers
  63. import kotlinx.coroutines.Job
  64. import kotlinx.coroutines.SupervisorJob
  65. import kotlinx.coroutines.async
  66. import kotlinx.coroutines.awaitAll
  67. import kotlinx.coroutines.cancel
  68. import kotlinx.coroutines.flow.onCompletion
  69. import kotlinx.coroutines.flow.onStart
  70. import kotlinx.coroutines.launch
  71. import kotlinx.coroutines.plus
  72. import soup.compose.material.motion.MotionConstants
  73. import soup.compose.material.motion.animation.materialSharedAxisX
  74. import soup.compose.material.motion.animation.rememberSlideDistance
  75. import kotlin.coroutines.cancellation.CancellationException
  76. import kotlin.math.PI
  77. import kotlin.math.sin
  78. /**
  79. * For invoking back press to the parent activity
  80. */
  81. @SuppressLint("ComposeCompositionLocalUsage")
  82. val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
  83. interface Tab : cafe.adriel.voyager.navigator.tab.Tab {
  84. suspend fun onReselect(navigator: Navigator) {}
  85. }
  86. abstract class Screen : Screen {
  87. override val key: ScreenKey = uniqueScreenKey
  88. }
  89. /**
  90. * A variant of ScreenModel.coroutineScope except with the IO dispatcher instead of the
  91. * main dispatcher.
  92. */
  93. val ScreenModel.ioCoroutineScope: CoroutineScope
  94. get() = ScreenModelStore.getOrPutDependency(
  95. screenModel = this,
  96. name = "ScreenModelIoCoroutineScope",
  97. factory = { key -> CoroutineScope(Dispatchers.IO + SupervisorJob()) + CoroutineName(key) },
  98. onDispose = { scope -> scope.cancel() },
  99. )
  100. interface AssistContentScreen {
  101. fun onProvideAssistUrl(): String?
  102. }
  103. @Composable
  104. fun DefaultNavigatorScreenTransition(
  105. navigator: Navigator,
  106. modifier: Modifier = Modifier,
  107. ) {
  108. val scope = rememberCoroutineScope()
  109. val view = LocalView.current
  110. val handler = remember {
  111. OnBackHandler(
  112. scope = scope,
  113. windowCornerRadius = view.getWindowRadius(),
  114. onBackPressed = navigator::pop,
  115. )
  116. }
  117. PredictiveBackHandler(enabled = navigator.canPop) { progress ->
  118. progress
  119. .onStart { handler.reset() }
  120. .onCompletion { e ->
  121. if (e == null) {
  122. handler.onBackConfirmed()
  123. } else {
  124. handler.onBackCancelled()
  125. }
  126. }
  127. .collect(handler::onBackEvent)
  128. }
  129. Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) {
  130. val currentSceneEntry = navigator.lastItem
  131. val showPrev by remember {
  132. derivedStateOf { handler.scale < 1f || handler.translationY != 0f }
  133. }
  134. val visibleItems = remember(currentSceneEntry, showPrev) {
  135. if (showPrev) {
  136. val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2)
  137. listOfNotNull(currentSceneEntry, prevSceneEntry)
  138. } else {
  139. listOfNotNull(currentSceneEntry)
  140. }
  141. }
  142. val slideDistance = rememberSlideDistance()
  143. val screenContent = remember {
  144. movableContentOf<Screen> { screen ->
  145. navigator.saveableState("transition", screen) {
  146. screen.Content()
  147. }
  148. }
  149. }
  150. visibleItems.forEachIndexed { index, backStackEntry ->
  151. val isPrev = index == 1 && visibleItems.size > 1
  152. if (!isPrev) {
  153. AnimatedContent(
  154. targetState = backStackEntry,
  155. transitionSpec = {
  156. val forward = navigator.lastEvent != StackEvent.Pop
  157. if (!forward && !handler.isReady) {
  158. // Pop screen without animation when predictive back is in use
  159. EnterTransition.None togetherWith ExitTransition.None
  160. } else {
  161. materialSharedAxisX(
  162. forward = forward,
  163. slideDistance = slideDistance,
  164. )
  165. }
  166. },
  167. modifier = Modifier
  168. .zIndex(1f)
  169. .graphicsLayer {
  170. this.alpha = handler.alpha
  171. this.transformOrigin = TransformOrigin(
  172. pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f,
  173. pivotFractionY = 0.5f,
  174. )
  175. this.scaleX = handler.scale
  176. this.scaleY = handler.scale
  177. this.translationY = handler.translationY
  178. this.clip = true
  179. this.shape = if (showPrev) {
  180. RoundedCornerShape(handler.windowCornerRadius.toFloat())
  181. } else {
  182. RectangleShape
  183. }
  184. }
  185. .then(
  186. if (showPrev) {
  187. Modifier.pointerInput(Unit) {
  188. // Animated content should not be interactive
  189. }
  190. } else {
  191. Modifier
  192. },
  193. ),
  194. content = {
  195. if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) {
  196. // Avoid drawing previous screen
  197. return@AnimatedContent
  198. }
  199. screenContent(it)
  200. },
  201. )
  202. } else {
  203. Box(
  204. modifier = Modifier
  205. .zIndex(0f)
  206. .drawWithCache {
  207. val bounds = Rect(Offset.Zero, size)
  208. val matrix = ColorMatrix().apply {
  209. // Reduce saturation and brightness
  210. setToSaturation(lerp(1f, 0.95f, handler.alpha))
  211. set(0, 4, lerp(0f, -25f, handler.alpha))
  212. set(1, 4, lerp(0f, -25f, handler.alpha))
  213. set(2, 4, lerp(0f, -25f, handler.alpha))
  214. }
  215. val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) }
  216. onDrawWithContent {
  217. drawIntoCanvas {
  218. it.saveLayer(bounds, paint)
  219. drawContent()
  220. it.restore()
  221. }
  222. }
  223. }
  224. .graphicsLayer {
  225. val blurRadius = 5.dp.toPx() * handler.alpha
  226. renderEffect = if (blurRadius > 0f) {
  227. BlurEffect(blurRadius, blurRadius)
  228. } else {
  229. null
  230. }
  231. }
  232. .pointerInput(Unit) {
  233. // bg content should not be interactive
  234. },
  235. content = { screenContent(backStackEntry) },
  236. )
  237. }
  238. }
  239. LaunchedEffect(currentSceneEntry) {
  240. // Reset *after* the screen is popped successfully
  241. // so that the correct transition is applied
  242. handler.setReady()
  243. }
  244. }
  245. }
  246. @Stable
  247. private class OnBackHandler(
  248. private val scope: CoroutineScope,
  249. val windowCornerRadius: Int,
  250. private val onBackPressed: () -> Unit,
  251. ) {
  252. var isReady = true
  253. private set
  254. var alpha by mutableFloatStateOf(1f)
  255. private set
  256. var scale by mutableFloatStateOf(1f)
  257. private set
  258. var translationY by mutableFloatStateOf(0f)
  259. private set
  260. var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT)
  261. private set
  262. private var containerSize = Size.Zero
  263. private var startPointY = Float.NaN
  264. var isPredictiveBack by mutableStateOf(false)
  265. private set
  266. private var animationJob: Job? = null
  267. set(value) {
  268. isReady = false
  269. field = value
  270. }
  271. fun updateContainerSize(size: Size) {
  272. containerSize = size
  273. }
  274. fun setReady() {
  275. reset()
  276. animationJob?.cancel()
  277. animationJob = null
  278. isReady = true
  279. isPredictiveBack = false
  280. }
  281. fun reset() {
  282. startPointY = Float.NaN
  283. }
  284. fun onBackEvent(backEvent: BackEventCompat) {
  285. if (!isReady) return
  286. isPredictiveBack = true
  287. swipeEdge = backEvent.swipeEdge
  288. val progress = LinearOutSlowInEasing.transform(backEvent.progress)
  289. scale = lerp(1f, 0.85f, progress)
  290. if (startPointY.isNaN()) {
  291. startPointY = backEvent.touchY
  292. }
  293. val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height
  294. val translateYDistance = containerSize.height / 20
  295. translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress
  296. }
  297. fun onBackConfirmed() {
  298. if (!isReady) return
  299. if (isPredictiveBack) {
  300. // Continue predictive animation and pop the screen
  301. val animationSpec = tween<Float>(
  302. durationMillis = MotionConstants.DefaultMotionDuration,
  303. easing = FastOutSlowInEasing,
  304. )
  305. animationJob = scope.launch {
  306. try {
  307. listOf(
  308. async {
  309. animate(
  310. initialValue = alpha,
  311. targetValue = 0f,
  312. animationSpec = animationSpec,
  313. ) { value, _ ->
  314. alpha = value
  315. }
  316. },
  317. async {
  318. animate(
  319. initialValue = scale,
  320. targetValue = scale - 0.05f,
  321. animationSpec = animationSpec,
  322. ) { value, _ ->
  323. scale = value
  324. }
  325. },
  326. ).awaitAll()
  327. } catch (e: CancellationException) {
  328. // no-op
  329. } finally {
  330. onBackPressed()
  331. alpha = 1f
  332. translationY = 0f
  333. scale = 1f
  334. }
  335. }
  336. } else {
  337. // Pop right away and use default transition
  338. onBackPressed()
  339. }
  340. }
  341. fun onBackCancelled() {
  342. // Reset states
  343. isPredictiveBack = false
  344. animationJob = scope.launch {
  345. listOf(
  346. async {
  347. animate(
  348. initialValue = scale,
  349. targetValue = 1f,
  350. ) { value, _ ->
  351. scale = value
  352. }
  353. },
  354. async {
  355. animate(
  356. initialValue = alpha,
  357. targetValue = 1f,
  358. ) { value, _ ->
  359. alpha = value
  360. }
  361. },
  362. async {
  363. animate(
  364. initialValue = translationY,
  365. targetValue = 0f,
  366. ) { value, _ ->
  367. translationY = value
  368. }
  369. },
  370. ).awaitAll()
  371. isReady = true
  372. }
  373. }
  374. }
  375. @Composable
  376. fun ScreenTransition(
  377. navigator: Navigator,
  378. transition: AnimatedContentTransitionScope<Screen>.() -> ContentTransform,
  379. modifier: Modifier = Modifier,
  380. content: ScreenTransitionContent = { it.Content() },
  381. ) {
  382. AnimatedContent(
  383. targetState = navigator.lastItem,
  384. transitionSpec = transition,
  385. modifier = modifier,
  386. label = "transition",
  387. ) { screen ->
  388. navigator.saveableState("transition", screen) {
  389. content(screen)
  390. }
  391. }
  392. }