Compose HorizontalPager二级联动

1,473 阅读13分钟

一年多没写文章了,抽时间记录一下工作中遇到的问题。

一、动态效果

     Compose目前官方并没有二级联动相关的支持。官方目前在material3中提供了ScrollableTabRow配合HorizontalPager可以实现Tab和Pager联动。昨天在项目中有此需求,写完之后做个笔记。

700_1694584434.gif

二、遇到问题

     快速写一个联动嵌套。利用ScrollableTabRow和HorizontalPager进行一级联动。在HorizontalPager切换的每个Pager内部也是ScrollableTabRow和HorizontalPager联动的二级页面。

1、代码编写

多级联动01.gif

@Composable
fun ScrollableTabRowSimple() {
    val scope = rememberCoroutineScope()
    val titles = remember {
        mutableStateListOf("掘金小册", "字节内部课")
    }
    val pagerState = rememberPagerState(pageCount = { titles.size })
    Column {
        ScrollableTabRow(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight(),
            selectedTabIndex = pagerState.currentPage,
            contentColor = Color.Black,
            divider = {},
            indicator = {
            }
        ) {
            titles.forEachIndexed { index, data ->
                val selected = pagerState.currentPage == index
                Box(
                    Modifier
                        .height(40.dp)
                        .fillMaxWidth()
                        .clickable {
                            scope.launch {
                                //Tab被点击后让Pager中内容动画形式滑动到目标页
                                pagerState.scrollToPage(index, 0f)
                            }
                        }, contentAlignment = Alignment.Center
                ) {
                    Text(
                        text = data,
                        fontSize = if (selected) 18.sp else 16.sp,
                        fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
                        color = if (selected) MaterialTheme.colorScheme.primary else Color.Black
                    )
                }
            }
        }
        HorizontalPager(
            state = pagerState,
            modifier = Modifier
                .fillMaxHeight()
        ) { pagePosition ->
            SecondPager()
        }
    }
}

private fun SecondPager() {
    val data = remember {
        mutableStateListOf("Android", "IOS", "人工智能", "开发人员", "代码人生", "阅读", "购买")
    }
    val pagerState = rememberPagerState(pageCount = { data.size })
    val scope = rememberCoroutineScope()
    Column {
        ScrollableTabRow(
            modifier = Modifier
                .wrapContentWidth()
                .wrapContentHeight(),
            selectedTabIndex = pagerState.currentPage,
            contentColor = Color.Black,
            edgePadding = 10.dp,
            divider = {},
            minTabWidth = 76.dp,
            indicator = { tabPositions ->
                if (tabPositions.isNotEmpty()) {
                    PagerTabIndicator(
                        tabPositions = tabPositions,
                        pagerState = pagerState,
                        paddingIndicatorWidth = 35.dp
                    )
                }
            }
        ) {
            data.forEachIndexed { index, data ->
                val selected = pagerState.currentPage == index
                Box(
                    Modifier
                        .height(40.dp)
                        .wrapContentWidth()
                        .clickable {
                            scope.launch {
                                pagerState.scrollToPage(index, 0f)//Tab被点击后让Pager中内容动画形式滑动到目标页
                            }
                        }, contentAlignment = Alignment.Center

                ) {
                    Text(
                        text = data, fontSize = 13.sp,
                        fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
                        color = if (selected) MaterialTheme.colorScheme.primary else Color.Black
                    )
                }
            }
        }
        HorizontalPager(
            state = pagerState,
            modifier = Modifier
                .fillMaxHeight()
        ) { pagePosition ->
            Box(
                Modifier
                    .fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(text = "页面::=${pagePosition}")
            }
        }


    }
}

2、遇到问题

     在二级页面滑动到最后一个,进行继续滑动切换一级页面时候出现了如下冲突:

多级联动02.gif

同样一级第二个页面“字节内部课”第一个Pager手势向右滑动到"掘金小册"也出现同样的问题。

scroll004.gif

看到上面效果,大家可能会想到了NestedScrollConnection,是否可以处理。在官方查阅NestedScrollConnection文档时候看到并非支持所有的滑动组件,而HorizontalPager是否有相关的支持,接下来分析。

image.png

三、分析问题

1、HorizontalPager源码

问题分析之前,先粗略浏览一下HorizontalPager源码,如果有时间可以自行仔细查看源码。此篇基于 androidx.compose.foundation:foundation:"1.5.0" 查看:

1、HorizontalPager方法最终将数据打包给了内部的Pager

@Composable
@ExperimentalFoundationApi
fun HorizontalPager(
    state: PagerState,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    pageSize: PageSize = PageSize.Fill,
    beyondBoundsPageCount: Int = 0,
    pageSpacing: Dp = 0.dp,
    verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
    flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
    userScrollEnabled: Boolean = true,
    reverseLayout: Boolean = false,
    key: ((index: Int) -> Any)? = null,
    pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(
        Orientation.Horizontal
    ),
    pageContent: @Composable PagerScope.(page: Int) -> Unit
) {
    Pager(
        state = state,
        modifier = modifier,
        contentPadding = contentPadding,
        pageSize = pageSize,
        beyondBoundsPageCount = beyondBoundsPageCount,
        pageSpacing = pageSpacing,
        orientation = Orientation.Horizontal,
        verticalAlignment = verticalAlignment,
        horizontalAlignment = Alignment.CenterHorizontally,
        flingBehavior = flingBehavior,
        userScrollEnabled = userScrollEnabled,
        reverseLayout = reverseLayout,
        key = key,
        pageNestedScrollConnection = pageNestedScrollConnection,
        pageContent = pageContent
    )
}

2、Pager内部通过PageState对LazyLayout进行了各种滑动和效果等测量记录:

@Composable
internal fun Pager(
    /** Modifier to be applied for the inner layout */
    modifier: Modifier,
    /** State controlling the scroll position */
    state: PagerState,
    .........
) {
    require(beyondBoundsPageCount >= 0) {
        "beyondBoundsPageCount should be greater than or equal to 0, " +
            "you selected $beyondBoundsPageCount"
    }

    val overscrollEffect = ScrollableDefaults.overscrollEffect()

    val pagerItemProvider = rememberPagerItemProviderLambda(
        state = state,
        pageContent = pageContent,
        key = key
    ) { state.pageCount }

    val measurePolicy = rememberPagerMeasurePolicy(
        state = state,
        contentPadding = contentPadding,
        reverseLayout = reverseLayout,
        orientation = orientation,
        beyondBoundsPageCount = beyondBoundsPageCount,
        pageSpacing = pageSpacing,
        pageSize = pageSize,
        horizontalAlignment = horizontalAlignment,
        verticalAlignment = verticalAlignment,
        itemProviderLambda = pagerItemProvider,
        pageCount = { state.pageCount },
    )

    val pagerFlingBehavior = remember(flingBehavior, state) {
        PagerWrapperFlingBehavior(flingBehavior, state)
    }

    val pagerSemantics = if (userScrollEnabled) {
        Modifier.pagerSemantics(state, orientation == Orientation.Vertical)
    } else {
        Modifier
    }

    val semanticState = rememberPagerSemanticState(
        state,
        reverseLayout,
        orientation == Orientation.Vertical
    )

    LazyLayout(
        modifier = modifier
            .then(state.remeasurementModifier)
            .then(state.awaitLayoutModifier)
            .then(pagerSemantics)
            .lazyLayoutSemantics(
                itemProviderLambda = pagerItemProvider,
                state = semanticState,
                orientation = orientation,
                userScrollEnabled = userScrollEnabled,
                reverseScrolling = reverseLayout
            )
            .clipScrollableContainer(orientation)
            .pagerBeyondBoundsModifier(
                state,
                beyondBoundsPageCount,
                reverseLayout,
                orientation
            )
            .overscroll(overscrollEffect)
            .scrollable(
                orientation = orientation,
                reverseDirection = ScrollableDefaults.reverseDirection(
                    LocalLayoutDirection.current,
                    orientation,
                    reverseLayout
                ),
                interactionSource = state.internalInteractionSource,
                flingBehavior = pagerFlingBehavior,
                state = state,
                overscrollEffect = overscrollEffect,
                enabled = userScrollEnabled
            )
            .dragDirectionDetector(state)
            .nestedScroll(pageNestedScrollConnection),
        measurePolicy = measurePolicy,
        prefetchState = state.prefetchState,
        itemProvider = pagerItemProvider
     )

}

    

      val overscrollEffect = ScrollableDefaults.overscrollEffect()
      overscrollEffect用于记录滚动效果,最终通过上的.scrollable(..overscrollEffect = overscrollEffect,...)进行绑定,并最终通过 DraggableElement持有的ScrollDraggableState通过dragBy->dispatchScroll->最终并提供过渡滚动绘制必要的数据 overscrollEffect.applyToScroll(scrollDelta, source, performScroll)。最终通过 LazyLayout的Modifier.overscroll(overscrollEffect)来渲染过渡滚动效果。有时间可以自己看看内部源码。

      val measurePolicy = rememberPagerMeasurePolicy(state = state...)
      measurePolicy 通过滑动过程进行对其页面测量并通过PagerState.applyMeasureResult将测量结果返回给PagerState。用于更新PageState里面的canScrollForward、scrollToBeConsumed、canScroollforward、canScrollBackward等值。

      val pagerFlingBehavior = remember(flingBehavior, state) {PagerWrapperFlingBehavior(flingBehavior, state)} 对快速等滑动提供给更好的多交互效果。自行查看源码。

      val pagerSemantics = if (userScrollEnabled) { Modifier.pagerSemantics(state, orientation == Orientation.Vertical) } else { Modifier }
     pagerSemantics点进去可以发现,它决定着滑动切换到下一个页面或者上一个页面【上下左右】,这里可以看到userScrollEnabled决定是否可以进行页面切换。也可以看到最终调用了PagerState的animateToNextPage和animateToPreviousPage,并在其方法内部进行具体的页面跳转索引计算,最后进行跳转同时进行页面测量更新。

image.png

2、PagerState相关参数

PagerState在上面部分可以看到,滑动部分和测量部分的很多信息都被更新到其中,作为提供给开发者的状态集,很有必要看看其内部各种参数:

1、PagerState

      如果经常查看相关事件源码的就明白,基本能够提供滑动相关状态和数据的就是它了。大概浏览,基本都可以看到当前的页面索引、总的页面数、

image.png       currentPage: currentPage一直会提供当前手势过程中的页面,用来获取操作过程中所操作的页面索引,进行其他设置。

      pageCount:总的Pager个数

      initialPage:初始化首次显示的页面,可以用来设置其他页面跳转到此页面时候设置跳转目标Pager

      currentPageOffsetFraction:

animalenenen.gif

如上所示:当前页面拖动距离距离自身未拖动时候的距离、此数值是-0.5-0.0-0.5之间,默认未拖动情况是0.0,当手势拖动向左滑动到和上一个页面中间此数值从0.0变小逐渐接近到-0.5会变为上一个页面的0.5。相反手势向右拖动,此数值从0.0逐渐接近0.5,当页面滑动到一半时候立马变为-0.4同样变为下一页距离默认位置之间的距离。

      initialPage:初始化首次显示的页面,可以用来设置其他页面跳转到此页面时候设置跳转目标Pager

      interactionSource: 当此列表被拖动时用于分发拖动事件。如果你想知道事件(或动画滚动)是否正在进行中,请使用 isScrollInProgress 属性。当然了也可以用interactionSource。当拖动时候下面应该为true,松手之后变为false。

val draggablePagerState = pagerState.interactionSource.collectIsDraggedAsState().value  
Log.e("pagerState===",draggablePagerState.toString())

      settledPage:某次滑动结束从当前页面跳转到下一页面【前后页面】,如果最终滑动结束会返回拖动页面索引和结束时候页面所在的索引。例如页面0拖动向左滑动,结束之后,返回0和1表示从0滑动到了1。

      scrollToPage:滚动(立即跳转)到指定页面。

      animateScrollToPage: 以动画方式滚动到指定的 [page]

2、userScrollEnabled

(来设置是否可以用户滑动)

3、pageNestedScrollConnection

这个目前看像是NestedScrollConnection可以用来监听滑动嵌套布局在滑动时候的相关数据

4、flingBehavior

(快速慢速等滑动行为) 例如快速滑动松开手之后能够滑动的最大页数,以及慢速滑动或者快速滑动所执行动画等....自行了解。

5、PagerState参数

根据上面PagerState提供的参数,可以知道当前页面索引、总的页面个数、设置可否滑动、初始化页面索引、接收子节点滑动交互信息、拖动事件等、crollToPage和animateScrollToPage也就是我们可以控制一级页面滑动到上一页或下一页。

3、分析

我们可以看到非一级页面切换的零界点滑动切换二级页面,是没有任何问题的。

horoscroll_gif01.gif

整体来看布局结构是滑动容器HorizontalPager嵌套了另一个HorizontalPager。每一个HorizontalPager 其内部实现了手势拖动下各个Pager之间的切换。首先我们知道,两个可滑动的容器嵌套,其事件消费优先级默认PointerEventPass.Main,所以会让子页面优先消费。而当二级HorizontalPager滑动到最后一个页面继续向左边拖动,此时事件不在消费,而是交给父级HorizontalPager去处理了滑动。

     就在这种临界条件下出现了滑动问题:

1、临界页面

如下两张图,当我们在末尾页面,拖动正常能够切换的距离,是无法切换一级HorizontalPager的内容的。而当距离拖动大距离时候是可以正常切换的。在上面我们分析过currentPageOffsetFraction,它表示当前页面偏移所占可用页面空间的比例达到接近0.5时,就会切换页面。

根据代码我们计算一下临界需要滑动多少距离才可以切换下个页面呢? 根据计算基本需要拖动到页面快中央。可见其对操作的交互影响还是很大。

val currentPageOffsetFraction: Float by derivedStateOf(structuralEqualityPolicy()) {
    val currentPagePositionOffset =
        layoutInfo.visiblePagesInfo.fastFirstOrNull { it.index == currentPage }?.offset ?: 0
    val pageUsedSpace = pageAvailableSpace.toFloat()
    if (pageUsedSpace == 0f) {
        // Default to 0 when there's no info about the page size yet.
        initialPageOffsetFraction
    } else {
        //当前页面滑动的距离 ➗ 可用页面大小
        ((-currentPagePositionOffset) / (pageUsedSpace)).coerceIn(
            MinPageOffset, MaxPageOffset
        )
    }
}

hozirotal_animal_03.gif

horizatal_animal02.gif

image.png

2、事件冲突

如下图、当临界页面继续向左拖动一定距离别松手,向相反方向滑动,因为子组件是可以向右滑动,所以事件会交给子控件处理。导致下面问题:

hozirotal_animal_04.gif

3、总结

到这里,我们分析出了问题所在,解决两个问题:滑动短距离就切换页面。不能触发子页面的拖动事件。

三、解决问题

如何做到临界拖动时不触发子组件的拖动事件且滑动短距离就切换页面呢?

还记得上文PagerState里面的userScrollEnabled参数。PagerState中currentPage 和 pageCount-1能够让我们知道最后一个页面。迫不及待根据这个条件试一试。代码如下:

HorizontalPager{
  HorizontalPager(
     userScrollEnabled = pagerState.currentPage != pagerState.pageCount -1,
     state = pagerState,
     ....)
     }

滑动效果:

hoziratal_animal_05.gif 通过判断是最后一个页面设置userScrollEnabled = false,实现了最后一个页面不触发拖动效果,但是父页面也无法正常滑动。有时间的可以看看源码,userScrollEnabled最终设置到了 DraggableElement->DraggableNode中当onPointerEvent接收到下发事件之后调用pointerInputNode中通过判断userScrollEnabled == false取消了事件分发。下面if (!enabled) return@SuspendingPointerInputModifierNode中enabled即参数userScrollEnabled。

internal class DraggableNode(
    private var state: DraggableState,
    private var canDrag: (PointerInputChange) -> Boolean,
    private var orientation: Orientation,
    private var enabled: Boolean,
    private var interactionSource: MutableInteractionSource?,
    private var startDragImmediately: () -> Boolean,
    private var onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit,
    private var onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit,
    private var reverseDirection: Boolean
) : DelegatingNode(), PointerInputModifierNode {
    // Use wrapper lambdas here to make sure that if these properties are updated while we suspend,
    // we point to the new reference when we invoke them.
    private val _canDrag: (PointerInputChange) -> Boolean = { canDrag(it) }
    private val _startDragImmediately: () -> Boolean = { startDragImmediately() }
    private val velocityTracker = VelocityTracker()

    private val pointerInputNode = delegate(SuspendingPointerInputModifierNode {
        // TODO: conditionally undelegate when aosp/2462416 lands?
        //这里如果userScrollEnabled = false;直接retruen
        if (!enabled) return@SuspendingPointerInputModifierNode
        coroutineScope {
            launch(start = CoroutineStart.UNDISPATCHED) {
                while (isActive) {
                    var event = channel.receive()
                    if (event !is DragStarted) continue
                    processDragStart(event)
                    try {
                        state.drag(MutatePriority.UserInput) {
                            while (event !is DragStopped && event !is DragCancelled) {
                                (event as? DragDelta)?.let { dragBy(it.delta.toFloat(orientation)) }
                                event = channel.receive()
                            }
                        }
                        if (event is DragStopped) {
                            processDragStop(event as DragStopped)
                        } else if (event is DragCancelled) {
                            processDragCancel()
                        }
                    } catch (c: CancellationException) {
                        processDragCancel()
                    }
                }
            }
            try {
                awaitPointerEventScope {
                    while (isActive) {
                        awaitDownAndSlop(
                            _canDrag,
                            _startDragImmediately,
                            velocityTracker,
                            orientation
                        )?.let {
                            var isDragSuccessful = false
                            try {
                                isDragSuccessful = awaitDrag(
                                    it.first,
                                    it.second,
                                    velocityTracker,
                                    channel,
                                    reverseDirection,
                                    orientation
                                )
                            } catch (cancellation: CancellationException) {
                                isDragSuccessful = false
                                if (!isActive) throw cancellation
                            } finally {
                                val event = if (isDragSuccessful) {
                                    val velocity = velocityTracker.calculateVelocity()
                                    velocityTracker.resetTracking()
                                    DragStopped(velocity * if (reverseDirection) -1f else 1f)
                                } else {
                                    DragCancelled
                                }
                                channel.trySend(event)
                            }
                        }
                    }
                }
            } catch (exception: CancellationException) {
                if (!isActive) {
                    throw exception
                }
            }
        }
    })

只要能拿到滑动方向和距离,我们可以通过PagerState提供的 animateToNextPage进行自由页面切换。常见的Modifier.pointerInput、Modifier.scrollable、Modifier.draggable等都可以,当然了像Modifier.draggable这样的比较方便,减少很多不必要的计算。

通过Modifier.draggable试一试,draggable也是DraggableElement最终DraggableNode里实现的。draggable的state实时提供着拖动时的增量,且我们可以通过正负来判断其拖动的方向。

HorizontalPager(
    userScrollEnabled = pagerState.currentPage != pagerState.pageCount - 1,
    state = pagerState,
    modifier = Modifier
        .fillMaxHeight()
        .draggable(
            state = rememberDraggableState { onDetail ->
                scope.launch {
                    Log.e("onPostScroll2 consumed: Velocity x=", onDetail.toString()) 
                }
            },
            orientation = Orientation.Horizontal,
            enabled = true
        ),
) {}

当向右边拖动增量大于30,通过一级的PagerState.animateToNextPage(current+1)切换到下一页。代码如下:

HorizontalPager(
    userScrollEnabled = pagerState.currentPage != pagerState.pageCount - 1,
    state = pagerState,
    modifier = Modifier
        .fillMaxHeight()
        .draggable(
            state = rememberDraggableState { onDetail ->
                scope.launch {
                    if (onDetail<-30){
                      topPagerState.animateScrollToPage(topPagerState.currentPage+1)
                   }
                }
            },
            orientation = Orientation.Horizontal,
            enabled = true
        ),
) {}

效果如下:实现了页面切换

horizatal_animal_06.gif

四、实现

根据前面分析结果,最终用userScrollEnabled禁止临界页面滑动解决了冲突问题。拿着Modifier.draggable获取滑动方向增量,并通过PagerState来控制页面的滑动。

到这里,相信大家应该能够愉快的完成二级联动了吧。为了考虑新手,文章也会分析各种情况,并完成代码。

编写最终代码过程会发现,临界场景比较多,代码编写逻辑比较繁杂。所以尽可能的学习源码分装,像PagerState一样,将其页面切换,判断等逻辑统统放入其中。

//因为需要一级二级PagerState、给一个滑动增量
class DraggablePagerState(
    levelState: PagerState,
    level2State: PagerState,
    private var draggableOffset: Int = 36
) {
    //顶部一级HorizontalPager的状态
    private var mLevelState: MutableState<PagerState> = mutableStateOf(levelState)
    //二级HorizontalPager的状态
    private var mLevel2State: MutableState<PagerState> = mutableStateOf(level2State)
}

1、二级联动临界场景

我们需要找到所有需要处理滑动的场景情况。避免遗漏,如下场景是可以看到的:

1、当一级在第一个页面,如果一级页面数>1那么,就需要在末尾(二级最后一个页面)进行处理。代码条件为:

image.png

//mLevelState.currentPage == 0 即 当一级在第一个页面
//mLevel2State.currentPage == mLevel2State.pageCount - 1) 即 末尾(二级最后一个页面)进行处理
if (mLevelState.currentPage == 0 && (mLevel2State.currentPage == mLevel2State.pageCount - 1))

2、当一级在中间页面,一级页面数着时必定>=3,在内容页第一个页面和最后一个页面都需要进行处理。

image.png

//确保是一级页面的中间页面
if (mLevelState.currentPage != 0 && mLevelState.currentPage != mLevelState.pageCount - 1 &&
//判断如果是内容页面的第一个页面或者最后一个页面    
(mLevel2State.currentPage == mLevel2State.pageCount - 1) || (mLevel2State.currentPage == 0)

3、当一级在最后一个页面,且>1时,就需要处理第一个页面进行处理。

image.png

//判断是一级最后一个页面 且 二级内容第一个页面需要对第一个页面进行滑动处理。
if (mLevelState.currentPage == mLevelState.pageCount - 1 && (mLevel2State.currentPage == 0))

定义一个场景对象:

sealed class PageScrollConfigType {
    //一级的第一个页面,二级的最后一个页面、手势向左边拖动,需要切换到一级的下一个页面
    object LeftPageType : PageScrollConfigType()

    //非一级的第一个和非一级最后一个页面,也就是一级的中间页面,切换时候需要考虑:手势左拖动切换到一级的上一个页面,手势右拖动切换到一级的下一个页面,那么二级需要禁止第一个和最后一个userScrollEnabled,进行拖拽操作
    object CenterPageType : PageScrollConfigType()

    //表示一级最后一个页面,需要处理第一个即可
    object RightPageType : PageScrollConfigType()
    //默认不需处理
    object DefaultPageType : PageScrollConfigType()
}

上述场景是不可滑动的需要拖动调用PagerState的页面切换方法,所以我们提供给外部userScrollEnable

private var userScrollEnabled by mutableStateOf(true)


fun initUserScrollEnableType() {
    userScrollEnabledType =
        if (mLevelState.currentPage == 0 && (mLevel2State.currentPage == mLevel2State.pageCount - 1)) {
            PageScrollConfigType.LeftPageType
        } else (if (mLevelState.currentPage != 0 && mLevelState.currentPage != mLevelState.pageCount - 1 &&
            ((mLevel2State.currentPage == mLevel2State.pageCount - 1) || (mLevel2State.currentPage == 0))
        ) {
            PageScrollConfigType.CenterPageType
        } else if (mLevelState.currentPage == mLevelState.pageCount - 1 && (mLevel2State.currentPage == 0)) {
            PageScrollConfigType.RightPageType
        } else PageScrollConfigType.DefaultPageType)

    userScrollEnabled = userScrollEnabledType == PageScrollConfigType.DefaultPageType
}

2、最终代码

DraggablePagerState作为逻辑处分装,最终代码如下:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberDraggablePagerState(
    levelState: PagerState,
    level2State: PagerState
): DraggablePagerState {
    return remember(levelState, level2State) {
        DraggablePagerState(levelState, level2State)
    }
}

@OptIn(ExperimentalFoundationApi::class)
class DraggablePagerState(
    levelState: PagerState,
    level2State: PagerState,
    private var draggableOffset: Int = 36
) {
    private var mLevelState by mutableStateOf(levelState)
    private var mLevel2State by mutableStateOf(level2State)

    //默认是可以滑动的
    private var userScrollEnabled by mutableStateOf(true)
    private var userScrollEnabledType by
    mutableStateOf<PageScrollConfigType>(PageScrollConfigType.DefaultPageType)

    fun mLevelState(): PagerState = mLevelState
    fun userScrollEnabled(): Boolean = userScrollEnabled
    fun draggableEnabled(): Boolean = !userScrollEnabled
    sealed class PageScrollConfigType {
        //一级的第一个页面,二级的最后一个页面、手势向左边拖动,需要切换到一级的下一个页面
        object LeftPageType : PageScrollConfigType()

        //非一级的第一个和非一级最后一个页面,也就是一级的中间页面,切换时候需要考虑:手势左拖动切换到一级的上一个页面,手势右拖动切换到一级的下一个页面,那么二级需要禁止第一个和最后一个userScrollEnabled,进行拖拽操作
        object CenterPageType : PageScrollConfigType()

        //表示一级最后一个页面,需要处理第一个即可
        object RightPageType : PageScrollConfigType()
        //默认不需处理

        object DefaultPageType : PageScrollConfigType()
    }

    fun initUserScrollEnableType() {
        userScrollEnabledType =
            if (mLevelState.currentPage == 0 && (mLevel2State.currentPage == mLevel2State.pageCount - 1)) {
                PageScrollConfigType.LeftPageType
            } else (if (mLevelState.currentPage != 0 && mLevelState.currentPage != mLevelState.pageCount - 1 &&
                ((mLevel2State.currentPage == mLevel2State.pageCount - 1) || (mLevel2State.currentPage == 0))
            ) {
                PageScrollConfigType.CenterPageType
            } else if (mLevelState.currentPage == mLevelState.pageCount - 1 && (mLevel2State.currentPage == 0)) {
                PageScrollConfigType.RightPageType
            } else PageScrollConfigType.DefaultPageType)

        userScrollEnabled = userScrollEnabledType == PageScrollConfigType.DefaultPageType
    }

    suspend fun setDraggableOnDetailToScrollToPage(onDetail: Float) {

        when (userScrollEnabledType) {
            PageScrollConfigType.LeftPageType -> {
                //切换第一项的切换
                if (onDetail < -draggableOffset) {//右滑动
                    mLevelState.scrollToPage(mLevelState.currentPage + 1)
                }
                //切换到上一页
                else if (onDetail > draggableOffset && mLevel2State.canScrollBackward && !mLevel2State.isScrollInProgress) {
                    mLevel2State.animateScrollToPage(mLevel2State.currentPage - 1)
                }
            }

            PageScrollConfigType.CenterPageType -> {
                if (mLevel2State.currentPage == 0) {
                    if (onDetail > draggableOffset) {//一级页面向左边Tab滑动,手势向右边走
                        mLevelState.animateScrollToPage(
                            mLevelState.currentPage - 1,
                            animationSpec = spring(stiffness = Spring.StiffnessMedium)
                        )
                    } else if (onDetail < -draggableOffset && mLevel2State.canScrollForward()) {//向右边滑动
                        mLevel2State.animateScrollToPage(
                            mLevel2State.currentPage + 1,
                            animationSpec = spring(stiffness = Spring.StiffnessMedium)
                        )
                    }
                } else if (mLevel2State.currentPage == mLevel2State.pageCount - 1) {
                    if (onDetail > draggableOffset) {//左边
                        mLevel2State.animateScrollToPage(
                            mLevel2State.currentPage - 1,
                            animationSpec = spring(stiffness = Spring.StiffnessMedium)
                        )
                    } else if (onDetail < -6 && mLevel2State.canScrollBackward()) {//左边滑动
                        mLevelState.animateScrollToPage(
                            mLevelState.currentPage + 1,
                            animationSpec = spring(stiffness = Spring.StiffnessMedium)
                        )
                    }
                }
            }

            PageScrollConfigType.RightPageType -> {
                if (onDetail < -draggableOffset && mLevel2State.canScrollForward()) {//右滑动
                    mLevel2State.animateScrollToPage(
                        mLevel2State.currentPage + 1,
                        animationSpec = spring(stiffness = Spring.StiffnessMedium)
                    )

                } else if (onDetail > draggableOffset) {
                    mLevelState.animateScrollToPage(
                        mLevelState.currentPage - 1,
                        animationSpec = spring(stiffness = Spring.StiffnessMedium)
                    )
                }
            }

            else -> {}
        }
    }


    private fun PagerState.canScrollForward(): Boolean {
        return canScrollForward && !isScrollInProgress
    }

    private fun PagerState.canScrollBackward(): Boolean {
        return canScrollBackward && !isScrollInProgress
    }


}

页面部分代码,只需要在二级页面初始化,传参数,设置userScrollEnabled即可:

val draggableState = rememberDraggablePagerState(topPagerState, pagerState)
draggableState.initUserScrollEnableType()
HorizontalPager(
    userScrollEnabled = draggableState.userScrollEnabled(),
    state = pagerState,
    modifier = Modifier
        .fillMaxHeight()
        .draggable(
            state = rememberDraggableState { onDetail ->
                scope.launch {
                    draggableState.setDraggableOnDetailToScrollToPage(onDetail)
                }
            },
            orientation = Orientation.Horizontal,
            enabled = (draggableState.draggableEnabled())
        ),
) { pagePosition ->

animal_end.gif

五、拓展

     当然了,如果有时间,你可以通过NestedScrollConnection在顶级解决这个问题,或者增加切换效果,可以写多级联动,当时分析了多集联动可能需要设置节点PagerState进行处理,场景比较复杂,如果你完成了可以贴上连接,一起学习。 下一篇,滑动吸附。

animalllllendend.gif