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