Compose fournit diverses API pour vous aider à détecter les gestes générés à partir des interactions des utilisateurs. Ces API couvrent un large éventail de cas d'utilisation :
Les API de niveau supérieur sont conçues pour couvrir les gestes les plus fréquemment utilisés. Par exemple, le modificateur
clickable
permet de détecter facilement un clic. Il fournit également des fonctionnalités d'accessibilité et affiche des indicateurs visuels lorsque l'utilisateur appuie sur l'écran (comme des ondulations).D'autres détecteurs de gestes moins courants, comme
PointerInputScope.detectTapGestures
ouPointerInputScope.detectDragGestures
, offrent plus de flexibilité à un niveau inférieur. Par contre, ils n'incluent pas les autres fonctionnalités.
Appui et pression
Le modificateur clickable
permet aux applications de détecter les clics sur l'élément auquel il est appliqué.
@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 }
)
}
Lorsque vous avez besoin de plus de flexibilité, vous pouvez fournir un détecteur de gestes tactiles via le modificateur 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 */ }
)
}
Défilement
Modificateurs Scroll
Les modificateurs verticalScroll
et horizontalScroll
sont la solution la plus simple pour autoriser l'utilisateur à faire défiler un élément lorsque les limites de son contenu dépassent les contraintes de taille maximales. Avec les modificateurs verticalScroll
et horizontalScroll
, vous n'avez pas besoin de décaler le contenu ni d'effectuer une translation.
@Composable
fun ScrollBoxes() {
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
.verticalScroll(rememberScrollState())
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
Le ScrollState
vous permet de modifier la position de défilement ou d'obtenir son état actuel. Pour le créer avec des paramètres par défaut, utilisez 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))
}
}
}
Modificateur Scrollable
Le modificateur scrollable
diffère des modificateurs Scroll dans le sens où scrollable
détecte les gestes de défilement, mais ne décale pas son contenu. Un ScrollableState
est nécessaire pour que ce modificateur fonctionne correctement.
Lorsque vous construisez ScrollableState
, vous devez fournir une fonction consumeScrollDelta
qui sera appelée à chaque étape de défilement (par saisie gestuelle, défilement fluide ou glissement d'un geste vif) avec le delta en pixels.
Cette fonction doit renvoyer la distance de défilement consommée, pour garantir que l'événement se propage correctement dans les cas où des éléments imbriqués disposent du modificateur scrollable
.
L'extrait de code suivant détecte les gestes et affiche une valeur numérique correspondant à un décalage, mais ne décale aucun élément :
@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())
}
}
Défilement imbriqué
Compose est compatible avec le défilement imbriqué, c'est-à-dire lorsque plusieurs éléments réagissent à un même geste de défilement. Une liste dans une autre liste est un exemple typique de défilement imbriqué. Une barre d'outils pouvant être réduite est un cas plus complexe.
Défilement imbriqué automatique
Le défilement imbriqué simple ne nécessite aucune action de votre part. Les gestes qui déclenchent une action de défilement sont automatiquement propagés depuis les enfants vers les parents. Ainsi, lorsque l'enfant ne peut plus faire défiler la page, le geste est géré par son élément parent.
Le défilement imbriqué automatique est compatible et fourni directement avec certains composants et modificateurs de Compose : verticalScroll
, horizontalScroll
, scrollable
, les API Lazy
et TextField
. Cela signifie que lorsque l'utilisateur fait défiler un enfant situé dans des composants imbriqués, les modificateurs précédents propagent les deltas de défilement aux parents intégrant une fonctionnalité de défilement imbriqué.
L'exemple suivant montre des éléments associés à un modificateur verticalScroll
dans un conteneur auquel un modificateur verticalScroll
a également été appliqué.
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)
)
}
}
}
}
Modificateur nestedScroll
Si vous devez créer un défilement coordonné plus complexe entre plusieurs éléments, le modificateur nestedScroll
vous offre plus de flexibilité en définissant une hiérarchie de défilements imbriqués.
Comme indiqué dans la section précédente, certains composants intègrent une fonctionnalité de défilement imbriqué. Toutefois, pour les composables sans défilement automatique, tels que Box
ou Column
, les deltas de défilement ne seront pas propagés dans le système de défilement imbriqué. Par conséquent, ils n'atteindront ni NestedScrollConnection
, ni le composant parent. Pour résoudre ce problème, vous pouvez utiliser nestedScroll
afin que les autres composants, y compris les composants personnalisés, prennent en charge cette fonctionnalité.
Interopérabilité du défilement imbriqué (à partir de Compose 1.2.0)
Lorsque vous essayez d'imbriquer des éléments View
à défilement dans des composables à défilement, ou inversement, vous pouvez rencontrer des problèmes.
Les problèmes les plus visibles se produisent généralement lorsque vous atteignez les limites de début ou de fin d'un élément enfant et que vous vous attendez à ce que le parent reprenne le défilement. Cependant, il se peut que ce comportement ne se produise pas ou ne fonctionne pas comme prévu.
Ce problème est dû aux attentes que les développeurs fondent sur les composables à défilement.
Les composables à défilement disposent d'une règle "nested-scroll-by-default", qui signifie que tout conteneur à défilement doit participer à la chaîne de défilement imbriquée, à la fois en tant que parent via NestedScrollConnection
et en tant qu'enfant via NestedScrollDispatcher
.
Une fois sa limite atteinte, l'enfant doit déclencher un défilement imbriqué pour l'élément parent. Par exemple, cette règle permet aux composants Pager
et LazyRow
de Compose de fonctionner correctement ensemble. Toutefois, lorsque l'interopérabilité du défilement est assurée par ViewPager2
ou RecyclerView
, comme ces composants n'implémentent pas NestedScrollingParent3
, le défilement continu de l'enfant vers le parent est impossible.
Pour permettre l'interopérabilité du défilement imbriqué entre des éléments View
à défilement et des composables à défilement, imbriqués dans les deux sens, vous pouvez utiliser l'API dédiée. Cela permettra de limiter ces problèmes dans les scénarios suivants.
Vue parente coopérante contenant un ComposeView enfant
Un View
parent coopérant est une vue qui implémente déjà NestedScrollingParent3
et peut donc recevoir des deltas de défilement d'un composable enfant imbriqué coopérant. Dans ce cas, ComposeView
agirait en tant qu'enfant et devrait implémenter (indirectement) NestedScrollingChild3
.
androidx.coordinatorlayout.widget.CoordinatorLayout
est un exemple de parent collaborant.
Pour assurer l'interopérabilité du défilement imbriqué entre des conteneurs parents View
à défilement et des composables enfants imbriqués à défilement, vous pouvez utiliser rememberNestedScrollInteropConnection()
.
rememberNestedScrollInteropConnection()
autorise et mémorise le NestedScrollConnection
qui permet l'interopérabilité du défilement imbriqué entre un View
parent implémentant NestedScrollingParent3
et un enfant Compose. Il doit être utilisé conjointement avec un modificateur nestedScroll
. Comme le défilement imbriqué est activé par défaut du côté de Compose, vous pouvez utiliser cette connexion pour activer le défilement imbriqué côté View
et ajouter la logique Glue nécessaire entre Views
et les composables.
Il est fréquent d'utiliser CoordinatorLayout
, CollapsingToolbarLayout
et un composable enfant, comme illustré dans cet exemple :
<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>
Dans votre activité ou fragment, vous devez configurer le composable enfant et le NestedScrollConnection
requis :
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 parent contenant un AndroidView enfant
Ce scénario concerne l'implémentation de l'API d'interopérabilité du défilement imbriqué du côté de Compose, lorsqu'un composable parent contient un AndroidView
enfant. Le composant AndroidView
implémente NestedScrollDispatcher
, car il agit en tant qu'enfant d'un parent à défilement Compose, et aussi NestedScrollingParent3
, car il agit en tant que parent d'un enfant à défilement View
. Le parent Compose pourra alors recevoir des deltas de défilement imbriqué à partir d'un enfant à défilement imbriqué View
.
L'exemple suivant montre comment assurer l'interopérabilité du défilement imbriqué dans ce scénario, avec une barre d'outils Compose pouvant être réduite :
@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) {
// ...
}
}
// ...
}
L'exemple suivant montre comment utiliser l'API avec un modificateur 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)
}
}
)
}
}
Enfin, l'exemple suivant montre comment obtenir un comportement de type "faire glisser pour fermer la vue" en utilisant l'API d'interopérabilité du défilement imbriqué avec BottomSheetDialogFragment
:
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
}
}
}
Notez que rememberNestedScrollInteropConnection()
installe un NestedScrollConnection
dans l'élément auquel vous l'associez. NestedScrollConnection
assure la transmission des deltas depuis le niveau Compose vers le niveau View
. Cela permet à l'élément de participer au défilement imbriqué, sans activer le défilement automatique des éléments. Pour les composables sans défilement automatique, tels que Box
ou Column
, les deltas de défilement ne seront pas propagés dans le système de défilement imbriqué. Comme les deltas n'atteindront pas le NestedScrollConnection
fourni par rememberNestedScrollInteropConnection()
, ils ne pourront pas atteindre le composant View
parent. Pour résoudre ce problème, assurez-vous également de définir des modificateurs à défilement sur ces types de composables imbriqués. Pour en savoir plus, consultez la section précédente sur le défilement imbriqué.
Vue parente non coopérante contenant un ComposeView enfant
Un View non coopérant est une vue qui n'implémente pas les interfaces NestedScrolling
nécessaires côté View
. Cela signifie que l'interopérabilité du défilement imbriqué avec ces Views
ne fonctionne pas directement. RecyclerView
et ViewPager2
sont des exemples de Views
non coopérants.
Déplacement
Le modificateur draggable
est le point d'entrée de niveau supérieur des gestes de déplacement dans une orientation. Il indique également la distance de déplacement en pixels.
Notez que ce modificateur est semblable à scrollable
, dans la mesure où il ne détecte que le geste. Vous devez conserver l'état et le représenter à l'écran, par exemple en déplaçant l'élément via le modificateur 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!"
)
Si vous devez contrôler l'ensemble du geste de déplacement, utilisez plutôt le détecteur de gestes de déplacement, avec le modificateur 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
}
}
)
}
Balayage
Le modificateur swipeable
vous permet de faire glisser des éléments qui, une fois relâchés, s'animent généralement vers au moins deux points d'ancrage définis dans une orientation. Cette méthode est fréquemment utilisée pour implémenter un comportement de type "balayer pour fermer la vue".
Notez que ce modificateur ne déplace pas l'élément, il détecte seulement le geste. Vous devez conserver l'état et le représenter à l'écran, par exemple en déplaçant l'élément via le modificateur offset
.
L'état de balayage est requis dans le modificateur swipeable
. Il peut être créé et mémorisé avec rememberSwipeableState()
.
Cet état fournit également un ensemble de méthodes utiles pour animer de manière automatisée les ancrages (voir snapTo
, animateTo
, performFling
et performDrag
) ainsi que des propriétés pour observer la progression du déplacement.
Il est possible de configurer différents types de seuils pour le geste de balayage, par exemple FixedThreshold(Dp)
et FractionalThreshold(Float)
, et de définir un type spécifique pour chaque combinaison de points d'ancrage de début et de fin.
Pour plus de flexibilité, vous pouvez configurer la resistance
lorsque vous balayez l'écran au-delà des limites. Vous pouvez également définir la propriété velocityThreshold
, qui animera un balayage jusqu'à l'état suivant, même si les thresholds
de position ne sont pas atteints.
@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)
)
}
}
Gestes multipoint : panoramique, zoom, rotation
Le modificateur transformable
permet de détecter les gestes multipoint utilisés pour le panoramique, le zoom et la rotation. Il ne transforme pas les éléments, il détecte simplement les gestes.
@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()
)
}
Si vous devez combiner le zoom, le panoramique et la rotation avec d'autres gestes, vous pouvez utiliser le détecteur PointerInputScope.detectTransformGestures
.