• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.compose.pager
18 
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.DecayAnimationSpec
21 import androidx.compose.animation.rememberSplineBasedDecay
22 import androidx.compose.foundation.gestures.FlingBehavior
23 import androidx.compose.foundation.layout.Arrangement
24 import androidx.compose.foundation.layout.Box
25 import androidx.compose.foundation.layout.PaddingValues
26 import androidx.compose.foundation.layout.wrapContentSize
27 import androidx.compose.foundation.lazy.LazyColumn
28 import androidx.compose.foundation.lazy.LazyRow
29 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.LaunchedEffect
31 import androidx.compose.runtime.Stable
32 import androidx.compose.runtime.remember
33 import androidx.compose.runtime.snapshotFlow
34 import androidx.compose.ui.Alignment
35 import androidx.compose.ui.Modifier
36 import androidx.compose.ui.geometry.Offset
37 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
38 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
39 import androidx.compose.ui.input.nestedscroll.nestedScroll
40 import androidx.compose.ui.unit.Dp
41 import androidx.compose.ui.unit.Velocity
42 import androidx.compose.ui.unit.dp
43 import kotlinx.coroutines.flow.filter
44 
45 /** Library-wide switch to turn on debug logging. */
46 internal const val DebugLog = false
47 
48 @RequiresOptIn(message = "Accompanist Pager is experimental. The API may be changed in the future.")
49 @Retention(AnnotationRetention.BINARY)
50 annotation class ExperimentalPagerApi
51 
52 /** Contains the default values used by [HorizontalPager] and [VerticalPager]. */
53 @ExperimentalPagerApi
54 object PagerDefaults {
55     /**
56      * Remember the default [FlingBehavior] that represents the scroll curve.
57      *
58      * @param state The [PagerState] to update.
59      * @param decayAnimationSpec The decay animation spec to use for decayed flings.
60      * @param snapAnimationSpec The animation spec to use when snapping.
61      */
62     @Composable
63     fun flingBehavior(
64         state: PagerState,
65         decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
66         snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
67     ): FlingBehavior =
68         rememberSnappingFlingBehavior(
69             lazyListState = state.lazyListState,
70             decayAnimationSpec = decayAnimationSpec,
71             snapAnimationSpec = snapAnimationSpec,
72         )
73 
74     @Deprecated(
75         "Replaced with PagerDefaults.flingBehavior()",
76         ReplaceWith("PagerDefaults.flingBehavior(state, decayAnimationSpec, snapAnimationSpec)")
77     )
78     @Composable
79     fun rememberPagerFlingConfig(
80         state: PagerState,
81         decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
82         snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
83     ): FlingBehavior = flingBehavior(state, decayAnimationSpec, snapAnimationSpec)
84 }
85 
86 /**
87  * A horizontally scrolling layout that allows users to flip between items to the left and right.
88  *
89  * @param count the number of pages.
90  * @param modifier the modifier to apply to this layout.
91  * @param state the state object to be used to control or observe the pager's state.
92  * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
93  *   composed from the end to the start and [PagerState.currentPage] == 0 will mean the first item
94  *   is located at the end.
95  * @param itemSpacing horizontal spacing to add between items.
96  * @param flingBehavior logic describing fling behavior.
97  * @param key the scroll position will be maintained based on the key, which means if you add/remove
98  *   items before the current visible item the item with the given key will be kept as the first
99  *   visible one.
100  * @param content a block which describes the content. Inside this block you can reference
101  *   [PagerScope.currentPage] and other properties in [PagerScope].
102  * @sample com.google.accompanist.sample.pager.HorizontalPagerSample
103  */
104 @ExperimentalPagerApi
105 @Composable
HorizontalPagernull106 fun HorizontalPager(
107     count: Int,
108     modifier: Modifier = Modifier,
109     state: PagerState = rememberPagerState(),
110     reverseLayout: Boolean = false,
111     itemSpacing: Dp = 0.dp,
112     flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(state),
113     verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
114     key: ((page: Int) -> Any)? = null,
115     contentPadding: PaddingValues = PaddingValues(0.dp),
116     content: @Composable PagerScope.(page: Int) -> Unit,
117 ) {
118     Pager(
119         count = count,
120         state = state,
121         modifier = modifier,
122         isVertical = false,
123         reverseLayout = reverseLayout,
124         itemSpacing = itemSpacing,
125         verticalAlignment = verticalAlignment,
126         flingBehavior = flingBehavior,
127         key = key,
128         contentPadding = contentPadding,
129         content = content
130     )
131 }
132 
133 /**
134  * A vertically scrolling layout that allows users to flip between items to the top and bottom.
135  *
136  * @param count the number of pages.
137  * @param modifier the modifier to apply to this layout.
138  * @param state the state object to be used to control or observe the pager's state.
139  * @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
140  *   composed from the bottom to the top and [PagerState.currentPage] == 0 will mean the first item
141  *   is located at the bottom.
142  * @param itemSpacing vertical spacing to add between items.
143  * @param flingBehavior logic describing fling behavior.
144  * @param key the scroll position will be maintained based on the key, which means if you add/remove
145  *   items before the current visible item the item with the given key will be kept as the first
146  *   visible one.
147  * @param content a block which describes the content. Inside this block you can reference
148  *   [PagerScope.currentPage] and other properties in [PagerScope].
149  * @sample com.google.accompanist.sample.pager.VerticalPagerSample
150  */
151 @ExperimentalPagerApi
152 @Composable
VerticalPagernull153 fun VerticalPager(
154     count: Int,
155     modifier: Modifier = Modifier,
156     state: PagerState = rememberPagerState(),
157     reverseLayout: Boolean = false,
158     itemSpacing: Dp = 0.dp,
159     flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(state),
160     horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
161     key: ((page: Int) -> Any)? = null,
162     contentPadding: PaddingValues = PaddingValues(0.dp),
163     content: @Composable PagerScope.(page: Int) -> Unit,
164 ) {
165     Pager(
166         count = count,
167         state = state,
168         modifier = modifier,
169         isVertical = true,
170         reverseLayout = reverseLayout,
171         itemSpacing = itemSpacing,
172         horizontalAlignment = horizontalAlignment,
173         flingBehavior = flingBehavior,
174         key = key,
175         contentPadding = contentPadding,
176         content = content
177     )
178 }
179 
180 @ExperimentalPagerApi
181 @Composable
Pagernull182 internal fun Pager(
183     count: Int,
184     modifier: Modifier,
185     state: PagerState,
186     reverseLayout: Boolean,
187     itemSpacing: Dp,
188     isVertical: Boolean,
189     flingBehavior: FlingBehavior,
190     key: ((page: Int) -> Any)?,
191     contentPadding: PaddingValues,
192     verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
193     horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
194     content: @Composable PagerScope.(page: Int) -> Unit,
195 ) {
196     require(count >= 0) { "pageCount must be >= 0" }
197 
198     // Provide our PagerState with access to the SnappingFlingBehavior animation target
199     // TODO: can this be done in a better way?
200     state.flingAnimationTarget = { (flingBehavior as? SnappingFlingBehavior)?.animationTarget }
201 
202     LaunchedEffect(count) {
203         state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0)
204     }
205 
206     // Once a fling (scroll) has finished, notify the state
207     LaunchedEffect(state) {
208         // When a 'scroll' has finished, notify the state
209         snapshotFlow { state.isScrollInProgress }
210             .filter { !it }
211             .collect { state.onScrollFinished() }
212     }
213 
214     val pagerScope = remember(state) { PagerScopeImpl(state) }
215 
216     // We only consume nested flings in the main-axis, allowing cross-axis flings to propagate
217     // as normal
218     val consumeFlingNestedScrollConnection =
219         ConsumeFlingNestedScrollConnection(
220             consumeHorizontal = !isVertical,
221             consumeVertical = isVertical,
222         )
223 
224     if (isVertical) {
225         LazyColumn(
226             state = state.lazyListState,
227             verticalArrangement = Arrangement.spacedBy(itemSpacing, verticalAlignment),
228             horizontalAlignment = horizontalAlignment,
229             flingBehavior = flingBehavior,
230             reverseLayout = reverseLayout,
231             contentPadding = contentPadding,
232             modifier = modifier,
233         ) {
234             items(
235                 count = count,
236                 key = key,
237             ) { page ->
238                 Box(
239                     Modifier
240                         // We don't any nested flings to continue in the pager, so we add a
241                         // connection which consumes them.
242                         // See: https://github.com/google/accompanist/issues/347
243                         .nestedScroll(connection = consumeFlingNestedScrollConnection)
244                         // Constraint the content to be <= than the size of the pager.
245                         .fillParentMaxHeight()
246                         .wrapContentSize()
247                 ) {
248                     pagerScope.content(page)
249                 }
250             }
251         }
252     } else {
253         LazyRow(
254             state = state.lazyListState,
255             verticalAlignment = verticalAlignment,
256             horizontalArrangement = Arrangement.spacedBy(itemSpacing, horizontalAlignment),
257             flingBehavior = flingBehavior,
258             reverseLayout = reverseLayout,
259             contentPadding = contentPadding,
260             modifier = modifier,
261         ) {
262             items(
263                 count = count,
264                 key = key,
265             ) { page ->
266                 Box(
267                     Modifier
268                         // We don't any nested flings to continue in the pager, so we add a
269                         // connection which consumes them.
270                         // See: https://github.com/google/accompanist/issues/347
271                         .nestedScroll(connection = consumeFlingNestedScrollConnection)
272                         // Constraint the content to be <= than the size of the pager.
273                         .fillParentMaxWidth()
274                         .wrapContentSize()
275                 ) {
276                     pagerScope.content(page)
277                 }
278             }
279         }
280     }
281 }
282 
283 private class ConsumeFlingNestedScrollConnection(
284     private val consumeHorizontal: Boolean,
285     private val consumeVertical: Boolean,
286 ) : NestedScrollConnection {
onPostScrollnull287     override fun onPostScroll(
288         consumed: Offset,
289         available: Offset,
290         source: NestedScrollSource
291     ): Offset =
292         when (source) {
293             // We can consume all resting fling scrolls so that they don't propagate up to the
294             // Pager
295             NestedScrollSource.Fling -> available.consume(consumeHorizontal, consumeVertical)
296             else -> Offset.Zero
297         }
298 
onPostFlingnull299     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
300         // We can consume all post fling velocity on the main-axis
301         // so that it doesn't propagate up to the Pager
302         return available.consume(consumeHorizontal, consumeVertical)
303     }
304 }
305 
consumenull306 private fun Offset.consume(
307     consumeHorizontal: Boolean,
308     consumeVertical: Boolean,
309 ): Offset =
310     Offset(
311         x = if (consumeHorizontal) this.x else 0f,
312         y = if (consumeVertical) this.y else 0f,
313     )
314 
315 private fun Velocity.consume(
316     consumeHorizontal: Boolean,
317     consumeVertical: Boolean,
318 ): Velocity =
319     Velocity(
320         x = if (consumeHorizontal) this.x else 0f,
321         y = if (consumeVertical) this.y else 0f,
322     )
323 
324 /** Scope for [HorizontalPager] content. */
325 @ExperimentalPagerApi
326 @Stable
327 interface PagerScope {
328     /** Returns the current selected page */
329     val currentPage: Int
330 
331     /** The current offset from the start of [currentPage], as a ratio of the page width. */
332     val currentPageOffset: Float
333 }
334 
335 @ExperimentalPagerApi
336 private class PagerScopeImpl(
337     private val state: PagerState,
338 ) : PagerScope {
339     override val currentPage: Int
340         get() = state.currentPage
341     override val currentPageOffset: Float
342         get() = state.currentPageOffset
343 }
344 
345 /**
346  * Calculate the offset for the given [page] from the current scroll position. This is useful when
347  * using the scroll position to apply effects or animations to items.
348  *
349  * The returned offset can positive or negative, depending on whether which direction the [page] is
350  * compared to the current scroll position.
351  *
352  * @sample com.google.accompanist.sample.pager.HorizontalPagerWithOffsetTransition
353  */
354 @ExperimentalPagerApi
PagerScopenull355 fun PagerScope.calculateCurrentOffsetForPage(page: Int): Float {
356     return (currentPage + currentPageOffset) - page
357 }
358