1

I'm trying to implement a MotionLayout animation with swipe gesture using the androidx.constraintlayout.compose version 1.1.0-alpha09 library in Jetpack Compose. However, I'm facing an issue where the animation doesn't work as expected when using LazyColumn inside the LayoutMation and swipe on LazyColumn.

Here is my compose

@OptIn(ExperimentalMotionApi::class)
@Composable
fun HomeView(
    navController: NavController,
    viewModel: AuthViewModel = hiltViewModel()
) {

    val context = LocalContext.current

    val motionScene = remember { context.resources.openRawResource(R.raw.home_main_scene).readBytes().decodeToString() }

    val tabIndex = remember { mutableStateOf(0) }

    val motionState = rememberMotionLayoutState()

    val systemUiController = rememberSystemUiController()

    SideEffect {
        systemUiController.setNavigationBarColor(
            color = LightColors.lightDebianColor,
            darkIcons = true
        )
        systemUiController.setStatusBarColor(
            color = PrimaryDebianColors.primary,
        )
    }

    MotionLayout(
        motionScene = MotionScene(content = motionScene),
        motionLayoutState = motionState,
        modifier = Modifier.fillMaxSize(),
    ) {
        Box(
            modifier = Modifier.layoutId("hero"),
            contentAlignment = Alignment.Center
        ) {
            ShopHighLights()
        }

        Box(
            modifier = Modifier
                .layoutId("tabs")
                .padding(top = 16.dp, bottom = 8.dp)
        ) {
            UziaDefaultScrollableTabs(
                selectedTabIndex = tabIndex.value,
                onClick = { tabIndex.value = it })
        }

        Surface(
            modifier = Modifier
                .layoutId("back")
                .clip(RoundedCornerShape(topStart = 42.dp, topEnd = 42.dp)),
            color = LightColors.lightTealColor
        ) {
            Box(
                modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
                contentAlignment = Alignment.TopCenter
            ) {
                ActionStatusComponent()
            }
        }

        Surface(
            modifier = Modifier
                .layoutId("front")
                .fillMaxSize()
                .clip(RoundedCornerShape(topStart = 42.dp, topEnd = 42.dp)),
            color = LightColors.lightDebianColor
        ) {
            FoodGridList(columns = 2)
        }
    }
}

Here is the motion scene

{
  ConstraintSets: {
    start: {
      hero: {
        width: "spread",
        top: ["parent", "top"],
        start: ["parent", "start"],
        end: ["parent", "end"],
        translationY: 0,
        alpha: 1,
        scaleX: 1,
        scaleY: 1,
      },
      content: {
        width: "spread",
        height: "spread",
        start: ["parent", "start"],
        end: ["parent", "end"],
        top: ["hero","bottom"],
        bottom: ["parent","bottom"],
      },
    },
    end: {
      hero: {
        width: "spread",
        top: ["parent", "top"],
        start: ["parent", "start"],
        end: ["parent", "end"],
        translationY: -50,
        alpha: 0,
        scaleX: 0.8,
        scaleY: 0.8,
      },
      content: {
        width: "spread",
        height: "spread",
        start: ["parent", "start"],
        end: ["parent", "end"],
        top: ["parent","top"],
        bottom: ["parent","bottom"],
      },
    },
  },
  Transitions: {
    default: {
      from: "start",
      to: "end",
      onSwipe: {
        anchor: "content",
        direction: "bottom",
        side: "top",
      },
    }
  }
}

The issue seems to be related to how Jetpack Compose prioritizes the scroll event and swipe event. I've tried disabling and enabling the userScrollEnabled property of LazyColumn, but it doesn't provide the desired behavior. What I'm trying to achieve is an animation where the LazyColumn doesn't scroll until the animation completes. After the animation finishes, the LazyColumn should resume scrolling if the user continues swiping.

I suspect that the touch events are being intercepted or conflicting between LazyColumn and MotionLayout, causing the animation to not work as expected within the LazyColumn.

I'm looking for suggestions or insights on how to properly handle the interaction between MotionLayout and LazyColumn to achieve the desired animation behavior. I want the LazyColumn to pause scrolling during the animation and resume scrolling if the user continues swiping after the animation completes.

Any help or guidance would be greatly appreciated. Thank you!

2 Answers2

0

You need to have the swipe drive the progress of the motionLayout. Those connections need to be a programmatic and more explicit in compose. The key pieces of code are a NestedScrollConnection and a Box containing the LazyColumn:

    val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val height = toolbarHeight.value;

            if (height + available.y > maxPx) {
                toolbarHeight.value = maxPx
                return Offset(0f, maxPx - height)
            }

            if (height + available.y < minPx) {
                toolbarHeight.value = minPx
                return Offset(0f, minPx - height)
            }

            toolbarHeight.value += available.y
            return Offset(0f, available.y)
        }

    }
}
val progress = = 1 - (toolbarHeight.value - minPx) / (maxPx - minPx);
 MotionLayout(
        ...
        progress = progress
    )
 Box( Modifier .nestedScroll(nestedScrollConnection)) {
        LazyColumn() {
             
            }
        }
    }

Working code can be found here:

https://github.com/androidx/constraintlayout/tree/main/demoProjects/ExamplesComposeMotionLayout#motion-layout-as-collapsing-toolbar-for-lazy-column

hoford
  • 4,918
  • 2
  • 19
  • 19
  • I don't think the LazyColumn needs to be in a box for that to work but unfortunately this doesn't quite replicate the previous behaviour (when recyclerview inside motionlayout) because from collapsed state it will prioritise expanding the header before allowing nested scroll but I think it's better to allow recyclerview/lazycolumn to return to start before expanding header again. I don't see a solution to achieve that at the mo.... – hmac Aug 01 '23 at 10:50
  • Yes. MotionLayout Views had – hoford Aug 01 '23 at 20:56
0

Unfortunately these workarounds mean we can't use the onSwipe options (e.g autocomplete). If you'd like to prioritise the inner scroll down when in collapsed state (this was natural behaviour in xml views world) then you need to share the scroll state of the inner scrollable and include a piece in the nestedScrollConnection that checks if it can scroll back. Note for me the header completely collapses i.e my minPx = 0 but I think you can avoid using minPx either way if you simply define the headerScrollMaxPx correctly (ie. max height - min height)

// Can put in utils file
fun collapsingHeaderNestedScrollConnection(
    headerExpandedPx: MutableState<Float>,
    headerScrollMaxPx: Float,
    nestedCanScrollBack: State<Boolean>
): NestedScrollConnection =
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val expandedPx = headerExpandedPx.value

            val allowNestedScrollBack = available.y > 0 && nestedCanScrollBack.value
            if (allowNestedScrollBack) {
                return Offset(0f, 0f)
            }

            val scrollBeyondMax = expandedPx + available.y > headerScrollMaxPx
            if (scrollBeyondMax) {
                headerExpandedPx.value = headerScrollMaxPx
                return Offset(0f, headerScrollMaxPx - expandedPx)
            }

            val scrollBeyondCollapsed = expandedPx + available.y < 0
            if (scrollBeyondCollapsed) {
                headerExpandedPx.value = 0f
                return Offset(0f, -expandedPx)
            }

            // Else use all scroll to collapse/expand header (block nested scroll by consuming all y delta)
            headerExpandedPx.value += available.y
            return Offset(0f, available.y)
        }
    }

 @Composable
    fun MotionLayoutComposeView(
        items: List<NotificationSettingItem>
    ) {
        val scrollMaxPx = with(LocalDensity.current) {
            dimensionResource(id = R.dimen.collapsing_sweep_view_header_scroll_span_default)
                .roundToPx()
                .toFloat()
        }
        val headerExpandedPx = remember { mutableFloatStateOf(scrollMaxPx) }

        ManageNotificationsMotionLayout(
            headerExpandedPx = headerExpandedPx,
            headerScrollMaxPx = scrollMaxPx
        ) ...


@Composable
fun ManageNotificationsMotionLayout(
    headerExpandedPx: State<Float>,
    headerScrollMaxPx: Float,
    content: @Composable (MotionLayoutScope.() -> Unit)
) {
    val progress = 1 - headerExpandedPx.value / headerScrollMaxPx

    MotionLayout(
        progress = progress...

// Inner scroll content composable scope...
  val settingsScrollState = rememberLazyListState()
        val canScrollBackState = remember {
            derivedStateOf { settingsScrollState.canScrollBackward }
        }

        val nestedScrollConnection = remember {
            collapsingHeaderNestedScrollConnection(
                headerExpandedPx = headerExpandedPx,
                headerScrollMaxPx = headerScrollMaxPx,
                nestedCanScrollBack = canScrollBackState
            )
        }

        CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
            LazyColumn(
                state = settingsScrollState,
                modifier = modifier.nestedScroll(nestedScrollConnection)...
hmac
  • 267
  • 3
  • 9