Compose menyediakan berbagai API untuk membantu Anda mendeteksi gestur yang dihasilkan dari interaksi pengguna. API mencakup berbagai kasus penggunaan:
Beberapa di antaranya tingkat tinggi dan didesain untuk mencakup gestur yang paling umum digunakan. Misalnya, pengubah
clickable
memungkinkan deteksi klik yang mudah, dan pengubah juga menyediakan fitur aksesibilitas dan menampilkan indikator visual saat diketuk (seperti ripple).Ada juga pendeteksi gestur yang jarang digunakan yang menawarkan fleksibilitas yang lebih fleksibel pada tingkat lebih rendah, seperti
PointerInputScope.detectTapGestures
atauPointerInputScope.detectDragGestures
tetapi tidak menyertakan fitur tambahan.
Mengetuk dan menekan
Pengubah clickable
memungkinkan aplikasi mendeteksi klik pada elemen yang diterapkan.
@Composable
fun ClickableSample() {
val count = remember { mutableStateOf(0) }
// content that you want to make clickable
Text(
text = count.value.toString(),
modifier = Modifier.clickable { count.value += 1 }
)
}
Jika fleksibilitas lebih dibutuhkan, Anda dapat memberikan pendeteksi gestur ketuk melalui pengubah pointerInput
:
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { /* Called when the gesture starts */ },
onDoubleTap = { /* Called on Double Tap */ },
onLongPress = { /* Called on Long Press */ },
onTap = { /* Called on Tap */ }
)
}
Scroll
Pengubah scroll
Pengubah verticalScroll
dan horizontalScroll
memberikan cara termudah untuk memungkinkan pengguna men-scroll elemen jika batas kontennya lebih besar dari batasan ukuran maksimumnya. Dengan pengubah verticalScroll
dan horizontalScroll
, Anda tidak perlu menerjemahkan atau melakukan offset pada konten tersebut.
@Composable
fun ScrollBoxes() {
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.verticalScroll(rememberScrollState())
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
Dengan ScrollState
, Anda dapat mengubah posisi scroll atau mendapatkan status saat ini. Untuk membuatnya dengan parameter default, gunakan rememberScrollState()
.
@Composable
private fun ScrollBoxesSmooth() {
// Smoothly scroll 100px on first composition
val state = rememberScrollState()
LaunchedEffect(Unit) { state.animateScrollTo(100) }
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.padding(horizontal = 8.dp)
.verticalScroll(state)
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
Pengubah yang dapat di-scroll
Pengubah scrollable
berbeda dengan pengubah scroll dalam scrollable
tersebut yang mendeteksi gestur scroll, tetapi tidak mengimbangi kontennya. ScrollableState
diperlukan untuk pengubah ini agar berfungsi dengan benar.
Saat membuat ScrollableState
, Anda harus menyediakan fungsi consumeScrollDelta
yang akan dipanggil pada setiap langkah scroll (dengan input gestur, scroll halus, atau lempar) dengan delta dalam piksel.
Fungsi ini harus menampilkan jumlah jarak scroll yang digunakan untuk
memastikan peristiwa disebarkan dengan benar jika ada elemen
bertingkat yang memiliki pengubah scrollable
.
Cuplikan berikut mendeteksi gestur dan menampilkan nilai numerik tetapi tidak melakukan offset pada elemen apa pun:
@Composable
fun ScrollableSample() {
// actual composable state
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
// Scrollable state: describes how to consume
// scrolling delta and update offset
state = rememberScrollableState { delta ->
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
Scroll Bertingkat
Compose mendukung scroll bertingkat, yaitu beberapa elemen memberikan reaksi terhadap gestur scroll tunggal. Contoh umum scroll bertingkat adalah daftar di dalam daftar lain, dan kasus yang lebih kompleks adalah toolbar yang dapat diciutkan.
Scroll bertingkat otomatis
Scroll bertingkat sederhana tidak memerlukan tindakan dari Anda. Gestur yang memulai tindakan scroll disebarkan dari turunan ke induk secara otomatis, sehingga saat turunan tidak dapat men-scroll lagi, gestur tersebut akan ditangani oleh elemen induknya.
Scroll bertingkat otomatis didukung dan disediakan secara langsung oleh beberapa
komponen dan pengubah Compose: verticalScroll
, horizontalScroll
,
scrollable
, Lazy
API, dan TextField
. Artinya, ketika pengguna
men-scroll turunan internal komponen bertingkat, pengubah sebelumnya akan menyebarkan
delta scroll ke induk yang memiliki dukungan scroll bertingkat.
Contoh berikut menunjukkan elemen dengan pengubah verticalScroll
yang diterapkan di dalamnya dalam penampung yang juga memiliki pengubah verticalScroll
yang diterapkan ke elemen tersebut.
val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
Box(
modifier = Modifier
.background(Color.LightGray)
.verticalScroll(rememberScrollState())
.padding(32.dp)
) {
Column {
repeat(6) {
Box(
modifier = Modifier
.height(128.dp)
.verticalScroll(rememberScrollState())
) {
Text(
"Scroll here",
modifier = Modifier
.border(12.dp, Color.DarkGray)
.background(brush = gradient)
.padding(24.dp)
.height(150.dp)
)
}
}
}
}
Menggunakan pengubah nestedScroll
Jika Anda perlu membuat scroll terkoordinasi lanjutan di antara beberapa elemen,
pengubah
nestedScroll
memberi Anda lebih banyak fleksibilitas dengan menentukan hierarki scroll bertingkat.
Seperti yang disebutkan di bagian sebelumnya, beberapa komponen memiliki dukungan
scroll bertingkat bawaan. Namun, untuk composable yang tidak dapat di-scroll
secara otomatis, seperti Box
atau Column
, delta scroll pada komponen
tersebut tidak akan menyebar di sistem scroll bertingkat dan delta tidak akan menjangkau
NestedScrollConnection
atau komponen induk. Untuk mengatasi hal ini, Anda dapat menggunakan
nestedScroll
untuk memberikan dukungan tersebut kepada komponen lain, termasuk komponen
kustom.
Interop scroll bertingkat (Mulai dengan Compose 1.2.0)
Saat mencoba menyusun bertingkat elemen View
yang dapat di-scroll dalam
composable yang dapat di-scroll, atau sebaliknya, Anda mungkin mengalami masalah.
Masalah yang paling terlihat akan terjadi saat Anda men-scroll turunan dan mencapai batas awal
atau akhir, dan induk diharapkan mengambil alih scroll. Namun, perilaku
yang diharapkan ini mungkin tidak terjadi atau mungkin tidak berfungsi seperti yang diharapkan.
Masalah ini adalah hasil dari ekspektasi yang dibuat di composable yang dapat di-scroll.
Composable yang dapat di-scroll memiliki aturan "nested-scroll-by-default", yang berarti
setiap penampung yang dapat di-scroll harus berpartisipasi dalam rantai scroll bertingkat, baik sebagai
induk melalui
NestedScrollConnection
maupun sebagai turunan melalui
NestedScrollDispatcher
.
Turunan kemudian akan mendorong scroll bertingkat untuk induk saat turunan berada di
batas. Sebagai contoh, aturan ini memungkinkan Compose Pager
dan Compose LazyRow
untuk bekerja sama dengan baik. Namun, jika scroll interoperabilitas dilakukan
dengan ViewPager2
atau RecyclerView
, karena scroll ini tidak mengimplementasikan
NestedScrollingParent3
,
scrolling berkelanjutan dari turunan ke induk tidak dapat dilakukan.
Untuk mengaktifkan API interop scroll bertingkat antara elemen View
yang dapat di-scroll dan
composable yang dapat di-scroll, yang ditumpuk di kedua arah, Anda dapat menggunakan API interop
scroll bertingkat untuk mengurangi masalah ini, dalam skenario berikut.
View induk yang bekerja sama berisi ComposeView turunan
View
induk yang bekerja sama adalah yang sudah mengimplementasikan
NestedScrollingParent3
sehingga dapat menerima delta scroll dari composable turunan
bertingkat yang bekerja sama. Dalam hal ini, ComposeView
akan bertindak sebagai turunan dan
harus (secara tidak langsung) mengimplementasikan
NestedScrollingChild3
.
Salah satu contoh induk yang bekerja sama adalah
androidx.coordinatorlayout.widget.CoordinatorLayout
.
Jika memerlukan interoperabilitas scroll bertingkat antara penampung induk View
yang
dapat di-scroll dan composable turunan yang dapat di-scroll, Anda dapat menggunakan
rememberNestedScrollInteropConnection()
.
rememberNestedScrollInteropConnection()
mengizinkan dan mengingat
NestedScrollConnection
yang memungkinkan interoperabilitas scroll bertingkat antara induk View
yang
mengimplementasikan
NestedScrollingParent3
dan turunan Compose. Ini harus digunakan bersama pengubah
nestedScroll
. Karena scroll bertingkat diaktifkan secara default di sisi Compose,
Anda dapat menggunakan koneksi ini untuk mengaktifkan scroll bertingkat di sisi View
dan
menambahkan logika glue yang diperlukan antara Views
dan composable.
Kasus penggunaan sering menggunakan CoordinatorLayout
, CollapsingToolbarLayout
, dan
composable turunan, seperti ditunjukkan dalam contoh ini:
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="100dp"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsing_toolbar_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<!--...-->
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
Dalam Activity atau Fragment, Anda perlu menyiapkan composable turunan dan
NestedScrollConnection
yang diperlukan:
open class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<ComposeView>(R.id.compose_view).apply {
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
// Add the nested scroll connection to your top level @Composable element
// using the nestedScroll modifier.
LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
items(20) { item ->
Box(
modifier = Modifier
.padding(16.dp)
.height(56.dp)
.fillMaxWidth()
.background(Color.Gray),
contentAlignment = Alignment.Center
) {
Text(item.toString())
}
}
}
}
}
}
}
Composable induk yang berisi AndroidView turunan
Skenario ini mencakup implementasi API interop scroll bertingkat di
sisi Compose - jika Anda memiliki composable induk yang berisi AndroidView
turunan. AndroidView
akan mengimplementasikan
NestedScrollDispatcher
,
karena berfungsi sebagai turunan dari induk scroll Compose, serta
NestedScrollingParent3
, karena berfungsi sebagai induk untuk turunan scrolling View
. Induk Compose
akan dapat menerima delta scroll bertingkat dari View
turunan
bertingkat yang dapat di-scroll.
Contoh berikut menunjukkan cara melakukan interop scroll bertingkat dalam skenario ini, beserta toolbar Compose yang diciutkan:
@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(
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<RecyclerView>(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<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
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) {
// ...
}
}
// ...
}
Contoh ini menunjukkan cara menggunakan API dengan pengubah scrollable
:
@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(android.R.layout.list_item, null)
.apply {
// Nested scrolling interop is enabled when
// nested scroll is enabled for the root View
ViewCompat.setNestedScrollingEnabled(this, true)
}
}
)
}
}
Terakhir, contoh ini menunjukkan cara API interop scroll bertingkat digunakan dengan
BottomSheetDialogFragment
untuk mencapai perilaku tarik lalu tutup yang sukses:
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<ComposeView>(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
}
}
}
Perhatikan bahwa rememberNestedScrollInteropConnection()
akan menginstal
NestedScrollConnection
di elemen yang Anda pasangkan. NestedScrollConnection
bertanggung jawab untuk
mengirim delta dari level Compose ke level View
. Hal ini memungkinkan
elemen berpartisipasi dalam scroll bertingkat, tetapi tidak mengaktifkan
scrolling elemen secara otomatis. Untuk composable yang tidak dapat di-scroll
secara otomatis, seperti Box
atau Column
, delta scroll pada komponen tersebut
tidak akan menyebar di sistem scroll bertingkat dan delta tidak akan menjangkau
NestedScrollConnection
yang disediakan oleh rememberNestedScrollInteropConnection()
.
Oleh karena itu, delta tersebut tidak akan menjangkau komponen View
induk. Untuk mengatasi
hal ini, pastikan Anda juga menetapkan pengubah yang dapat di-scroll ke jenis composable
bertingkat ini. Anda dapat melihat bagian sebelumnya di
Scrolling bertingkat untuk informasi
yang lebih mendetail.
View induk yang tidak bekerja sama berisi ComposeView turunan
View yang tidak bekerja sama adalah View yang tidak mengimplementasikan antarmuka
NestedScrolling
yang diperlukan di sisi View
. Perlu diperhatikan bahwa ini berarti
interoperabilitas scroll bertingkat dengan Views
ini tidak langsung berfungsi
dari awal. Views
yang tidak bekerja sama adalah RecyclerView
dan ViewPager2
.
Menarik
Pengubah draggable
adalah titik entri tingkat tinggi untuk menarik gestur dalam satu orientasi, dan melaporkan jarak tarik dalam piksel.
Penting untuk diperhatikan bahwa pengubah ini mirip dengan scrollable
, karena pengubah hanya mendeteksi gestur. Anda harus menyimpan status dan merepresentasikannya di layar dengan, misalnya, memindahkan elemen melalui pengubah offset
:
var offsetX by remember { mutableStateOf(0f) }
Text(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offsetX += delta
}
),
text = "Drag me!"
)
Jika Anda perlu mengontrol seluruh gestur tarik, pertimbangkan untuk menggunakan detektor gestur tarik, melalui pengubah pointerInput
.
Box(modifier = Modifier.fillMaxSize()) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}
Fitur Geser
Pengubah
swipeable
memungkinkan Anda menarik elemen yang, jika dirilis, akan cenderung bergerak ke
dua atau beberapa titik link yang ditetapkan dalam orientasi. Penggunaan yang umum untuk hal ini adalah
dengan menerapkan pola 'geser untuk menutup'.
Perlu diperhatikan bahwa pengubah ini tidak memindahkan elemen, tetapi hanya
mendeteksi gestur. Anda perlu mempertahankan status dan menampilkannya di layar dengan, misalnya, memindahkan elemen melalui pengubah offset
.
Status geser diperlukan dalam pengubah swipeable
, dan dapat dibuat serta diingat dengan rememberSwipeableState()
.
Status ini juga menyediakan serangkaian metode yang berguna untuk secara terprogram menganimasikan ke anchor (lihat snapTo
, animateTo
, performFling
, dan performDrag
) serta properti untuk mengamati progres penarikan.
Gestur geser dapat dikonfigurasi untuk memiliki jenis nilai minimum yang berbeda, seperti FixedThreshold(Dp)
dan FractionalThreshold(Float)
, serta keduanya dapat berbeda untuk setiap kombinasi dari-ke titik link.
Untuk fleksibilitas yang lebih besar, Anda dapat mengonfigurasi resistance
saat menggeser melewati batas dan juga velocityThreshold
yang akan menggerakkan geser ke status berikutnya, meskipun thresholds
posisi belum tercapai.
@Composable
fun SwipeableSample() {
val width = 96.dp
val squareSize = 48.dp
val swipeableState = rememberSwipeableState(0)
val sizePx = with(LocalDensity.current) { squareSize.toPx() }
val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states
Box(
modifier = Modifier
.width(width)
.swipeable(
state = swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Box(
Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
.size(squareSize)
.background(Color.DarkGray)
)
}
}
Multisentuh: Menggeser, memperbesar/memperkecil, memutar
Untuk mendeteksi gestur multisentuh yang digunakan untuk menggeser, memperbesar/memperkecil, dan memutar, Anda dapat menggunakan pengubah transformable
. Pengubah ini tidak mengubah elemen dengan sendirinya, hanya mendeteksi gestur.
@Composable
fun TransformableSample() {
// set up all transformation states
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
Modifier
// apply other transformations like rotation and zoom
// on the pizza slice emoji
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
// add transformable to listen to multitouch transformation events
// after offset
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
Jika perlu menggabungkan perbesar/perkecil, penggeseran, dan rotasi dengan gestur lain, Anda dapat menggunakan deteksi PointerInputScope.detectTransformGestures
.