diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/pointerinput/MigrateSwipeable.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/pointerinput/MigrateSwipeable.kt new file mode 100644 index 000000000..9a8570cb6 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/pointerinput/MigrateSwipeable.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.snippets.touchinput.pointerinput + +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.SwipeableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.example.compose.snippets.touchinput.pointerinput.DragValue.Center +import com.example.compose.snippets.touchinput.pointerinput.DragValue.End +import com.example.compose.snippets.touchinput.pointerinput.DragValue.Start + +@OptIn(ExperimentalMaterialApi::class) +private object SwipeableStateExample { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_state_legacy] + class MySwitchState : SwipeableState(initialValue = DragValue.Start) + // [END android_compose_touchinput_pointerinput_migrate_swipeable_state_legacy] +} + +@OptIn(ExperimentalFoundationApi::class) +private object AnchoredDraggableStateExample { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_state_anchored] + class MySwitchState { + private val anchoredDraggableState = AnchoredDraggableState( + // [START_EXCLUDE] + initialValue = DragValue.Start, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { 125f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + } + // [END android_compose_touchinput_pointerinput_migrate_swipeable_state_anchored] +} + +@OptIn(ExperimentalFoundationApi::class) +// [START android_compose_touchinput_pointerinput_migrate_swipeable_access_offset] +@Composable +fun AnchoredDraggableBox() { + val state = remember { + AnchoredDraggableState( + // [START_EXCLUDE] + initialValue = DragValue.Start, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { 125f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + } + val density = LocalDensity.current + val anchors = remember { + DraggableAnchors { + // [START_EXCLUDE] + DragValue.Start at -100f + DragValue.Center at 0f + DragValue.End at 100f + // [END_EXCLUDE] + } + } + SideEffect { + state.updateAnchors(anchors) + } + Box( + Modifier.offset { IntOffset(x = state.requireOffset().toInt(), y = 0) } + ) +} +// [END android_compose_touchinput_pointerinput_migrate_swipeable_access_offset] + +@OptIn(ExperimentalFoundationApi::class) +private object AnchoredDraggableConstructorExample { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_anchors_constructor] + enum class DragValue { Start, Center, End } + + @Composable + fun AnchoredDraggableBox() { + val density = LocalDensity.current + val anchors = with(density) { + DraggableAnchors { + Start at -100.dp.toPx() + Center at 0f + End at 100.dp.toPx() + } + } + val state = remember { + AnchoredDraggableState( + anchors = anchors, + // [START_EXCLUDE silent] + initialValue = Start, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { 125f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + } + Box( + Modifier.offset { IntOffset(x = state.requireOffset().toInt(), y = 0) } + ) + } + // [END android_compose_touchinput_pointerinput_migrate_swipeable_anchors_constructor] +} + +@OptIn(ExperimentalFoundationApi::class) +private object AnchoredDraggableUpdateAnchorsExample { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_anchors_update] + enum class DragValue { Start, Center, End } + + @Composable + fun AnchoredDraggableBox() { + val state = remember { + AnchoredDraggableState( + // [START_EXCLUDE] + initialValue = Start, + positionalThreshold = { distance: Float -> distance * 0.5f }, + velocityThreshold = { 125f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + } + val density = LocalDensity.current + val anchors = with(density) { + DraggableAnchors { + Start at -100.dp.toPx() + Center at 0f + End at 100.dp.toPx() + } + } + SideEffect { + state.updateAnchors(anchors) + } + Box( + Modifier.offset { IntOffset(x = state.requireOffset().toInt(), y = 0) } + ) + } + // [END android_compose_touchinput_pointerinput_migrate_swipeable_anchors_update] +} + +@OptIn(ExperimentalFoundationApi::class) +private fun PositionalThresholdFraction() { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_positional_threshold_fraction] + val anchoredDraggableState = AnchoredDraggableState( + positionalThreshold = { distance -> distance * 0.5f }, + // [START_EXCLUDE] + initialValue = DragValue.Start, + velocityThreshold = { 125f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + // [END android_compose_touchinput_pointerinput_migrate_swipeable_positional_threshold_fraction] +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PositionalThresholdDp() { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_positional_threshold_dp] + val density = LocalDensity.current + val anchoredDraggableState = AnchoredDraggableState( + positionalThreshold = { with(density) { 56.dp.toPx() } }, + // [START_EXCLUDE] + initialValue = DragValue.Start, + velocityThreshold = { 125f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + // [END android_compose_touchinput_pointerinput_migrate_swipeable_positional_threshold_dp] +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun VelocityThreshold() { + // [START android_compose_touchinput_pointerinput_migrate_swipeable_velocity_threshold] + val density = LocalDensity.current + val anchoredDraggableState = AnchoredDraggableState( + velocityThreshold = { with(density) { 125.dp.toPx() } }, + // [START_EXCLUDE] + initialValue = DragValue.Start, + positionalThreshold = { distance: Float -> distance * 0.5f }, + snapAnimationSpec = spring(), + decayAnimationSpec = exponentialDecay() + // [END_EXCLUDE] + ) + // [END android_compose_touchinput_pointerinput_migrate_swipeable_velocity_threshold] +} + +private enum class DragValue { Start, Center, End } diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/scroll/NestedScrollModifiers.kt b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/scroll/NestedScrollModifiers.kt new file mode 100644 index 000000000..4167bf650 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/touchinput/scroll/NestedScrollModifiers.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.snippets.touchinput.scroll + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter +import androidx.recyclerview.widget.RecyclerView.VERTICAL +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.example.compose.snippets.R +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlin.math.roundToInt + +// [START android_compose_touchinput_scroll_nested_scroll_interop_compose_parent] +@Composable +private fun NestedScrollInteropComposeParentWithAndroidChildExample() { + val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() } + val toolbarOffsetHeightPx = remember { mutableStateOf(0f) } + + // Sets up the nested scroll connection between the Box composable parent + // and the child AndroidView containing the RecyclerView + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Updates the toolbar offset based on the scroll to enable + // collapsible behaviour + val delta = available.y + val newOffset = toolbarOffsetHeightPx.value + delta + toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f) + return Offset.Zero + } + } + } + + Box( + Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + ) { + TopAppBar( + // [START_EXCLUDE silent] + title = {}, + // [END_EXCLUDE] + modifier = Modifier + .height(ToolbarHeight) + .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) } + ) + + AndroidView( + { context -> + LayoutInflater.from(context) + .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply { + with(findViewById(R.id.main_list)) { + layoutManager = LinearLayoutManager(context, VERTICAL, false) + adapter = NestedScrollInteropAdapter() + } + }.also { + // Nested scrolling interop is enabled when + // nested scroll is enabled for the root View + ViewCompat.setNestedScrollingEnabled(it, true) + } + }, + // ... + ) + } +} + +private class NestedScrollInteropAdapter : + Adapter() { + val items = (1..10).map { it.toString() } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): NestedScrollInteropViewHolder { + return NestedScrollInteropViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.list_item, parent, false) + ) + } + + override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) { + // ... + } + + class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) { + fun bind(item: String) { + // ... + } + } + // [START_EXCLUDE] + override fun getItemCount(): Int = items.size + // [END_EXCLUDE] +} +// [END android_compose_touchinput_scroll_nested_scroll_interop_compose_parent] + +// [START android_compose_touchinput_scroll_view_in_compose_interop] +@Composable +fun ViewInComposeNestedScrollInteropExample() { + Box( + Modifier + .fillMaxSize() + .scrollable( + rememberScrollableState { + // View component deltas should be reflected in Compose + // components that participate in nested scrolling + it + }, + Orientation.Vertical + ) + ) { + AndroidView( + { context -> + LayoutInflater.from(context) + .inflate(R.layout.list_item, null) + .apply { + // Nested scrolling interop is enabled when + // nested scroll is enabled for the root View + ViewCompat.setNestedScrollingEnabled(this, true) + } + } + ) + } +} +// [END android_compose_touchinput_scroll_view_in_compose_interop] + +// [START android_compose_touchinput_scroll_bottom_sheet_interop] +class BottomSheetFragment : BottomSheetDialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false) + + rootView.findViewById(R.id.compose_view).apply { + setContent { + val nestedScrollInterop = rememberNestedScrollInteropConnection() + LazyColumn( + Modifier + .nestedScroll(nestedScrollInterop) + .fillMaxSize() + ) { + item { + Text(text = "Bottom sheet title") + } + items(10) { + Text( + text = "List item number $it", + modifier = Modifier.fillMaxWidth() + ) + } + } + } + return rootView + } + } +} +// [END android_compose_touchinput_scroll_bottom_sheet_interop] + +private val ToolbarHeight = 48.dp diff --git a/compose/snippets/src/main/res/layout/fragment_bottom_sheet.xml b/compose/snippets/src/main/res/layout/fragment_bottom_sheet.xml new file mode 100644 index 000000000..d3df0699c --- /dev/null +++ b/compose/snippets/src/main/res/layout/fragment_bottom_sheet.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/compose/snippets/src/main/res/layout/list_item.xml b/compose/snippets/src/main/res/layout/list_item.xml new file mode 100644 index 000000000..a42dc97ac --- /dev/null +++ b/compose/snippets/src/main/res/layout/list_item.xml @@ -0,0 +1,19 @@ + + + diff --git a/compose/snippets/src/main/res/layout/view_in_compose_nested_scroll_interop.xml b/compose/snippets/src/main/res/layout/view_in_compose_nested_scroll_interop.xml new file mode 100644 index 000000000..a5bc47489 --- /dev/null +++ b/compose/snippets/src/main/res/layout/view_in_compose_nested_scroll_interop.xml @@ -0,0 +1,20 @@ + + +