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 private 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 de défilement dans le sens où scrollable
détecte les gestes de défilement et capture les deltas, mais ne décale pas automatiquement son contenu. Cette tâche est déléguée à l'utilisateur via ScrollableState
, qui 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 private 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é
Le défilement imbriqué est un système dans lequel plusieurs composants de défilement contenus les uns dans les autres fonctionnent ensemble en réagissant à un seul geste de défilement et en communiquant leurs deltas de défilement (modifications).
Le système de défilement imbriqué permet de coordonner les composants qui sont déroulants et liés de manière hiérarchique (le plus souvent en partageant le même parent). Ce système lie les conteneurs de défilement et permet d'interagir avec les deltas de défilement qui sont propagés et partagés entre eux.
Compose propose plusieurs façons de gérer le défilement imbriqué entre les composables. 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é.
@Composable private fun AutomaticNestedScroll() { 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) ) } } } } }
Utiliser le 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é.
Cycle de défilement imbriqué
Le cycle de défilement imbriqué est le flux de deltas de défilement qui sont distribués vers le haut et le bas de l'arborescence hiérarchique via tous les composants (ou nœuds) qui font partie du système de défilement imbriqué, par exemple à l'aide de composants et de modificateurs de défilement, ou de nestedScroll
.
Phases du cycle de défilement imbriqué
Lorsqu'un événement de déclencheur (par exemple, un geste) est détecté par un composant à faire défiler, avant même que l'action de défilement ne soit déclenchée, les deltas générés sont envoyés au système de défilement imbriqué et passent par trois phases : pré-défilement, consommation de nœud et post-défilement.
Dans la première phase, avant le défilement, le composant qui a reçu les deltas d'événement de déclencheur distribue ces événements vers le haut, via l'arborescence de la hiérarchie, jusqu'au parent le plus élevé. Les événements delta remonteront ensuite, ce qui signifie que les deltas seront propagés du parent racine vers l'enfant qui a commencé le cycle de défilement imbriqué.
Cela permet aux parents de défilement imbriqués (composables utilisant nestedScroll
ou des modificateurs de défilement) d'effectuer une action avec le delta avant que le nœud lui-même ne puisse le consommer.
Dans la phase de consommation du nœud, le nœud lui-même utilise le delta qui n'a pas été utilisé par ses parents. C'est à ce moment que le mouvement de défilement est réellement effectué et visible.
Au cours de cette phase, l'enfant peut choisir de consommer la totalité ou une partie du défilement restant. Tout élément restant est renvoyé vers le haut pour passer par la phase post-défilement.
Enfin, dans la phase post-défilement, tout ce que le nœud lui-même n'a pas consommé sera renvoyé à ses ancêtres pour consommation.
La phase post-défilement fonctionne de la même manière que la phase pré-défilement, où n'importe quel parent peut choisir de consommer ou non.
Comme pour le défilement, à la fin d'un geste de glissement, l'intention de l'utilisateur peut être traduite en vitesse utilisée pour lancer (défiler à l'aide d'une animation) le conteneur à faire défiler. Le fling fait également partie du cycle de défilement imbriqué, et les vitesses générées par l'événement de glissement passent par des phases similaires: pré-fling, consommation de nœuds et post-fling. Notez que l'animation de balayage n'est associée qu'au geste tactile et qu'elle ne sera pas déclenchée par d'autres événements, tels que l'accessibilité ou le défilement matériel.
Participer au cycle de défilement imbriqué
La participation au cycle implique d'intercepter, de consommer et de signaler la consommation de deltas dans la hiérarchie. Compose fournit un ensemble d'outils pour influer sur le fonctionnement du système de défilement imbriqué et pour interagir directement avec lui, par exemple lorsque vous devez effectuer une action avec les deltas de défilement avant même qu'un composant à faire défiler ne commence à défiler.
Si le cycle de défilement imbriqué est un système agissant sur une chaîne de nœuds, le modificateur nestedScroll
permet d'intercepter et d'insérer ces modifications, et d'influencer les données (deltas de défilement) qui sont propagées dans la chaîne. Ce modificateur peut être placé n'importe où dans la hiérarchie et communique avec les instances de modificateur de défilement imbriquées dans l'arborescence afin de pouvoir partager des informations via ce canal. Les composants de base de ce modificateur sont NestedScrollConnection
et NestedScrollDispatcher
.
NestedScrollConnection
permet de répondre aux phases du cycle de défilement imbriqué et d'influencer le système de défilement imbriqué. Il se compose de quatre méthodes de rappel, chacune représentant l'une des phases de consommation: pré/post-défilement et pré/post-fling:
val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { println("Received onPreScroll callback.") return Offset.Zero } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { println("Received onPostScroll callback.") return Offset.Zero } }
Chaque rappel fournit également des informations sur le delta propagé : le delta available
pour cette phase particulière et le delta consumed
consommé dans les phases précédentes. Si vous souhaitez à tout moment arrêter de propager des deltas dans la hiérarchie, vous pouvez utiliser la connexion de défilement imbriquée:
val disabledNestedScrollConnection = remember { object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource ): Offset { return if (source == NestedScrollSource.SideEffect) { available } else { Offset.Zero } } } }
Tous les rappels fournissent des informations sur le type NestedScrollSource
.
NestedScrollDispatcher
initialise le cycle de défilement imbriqué. L'utilisation d'un répartiteur et l'appel de ses méthodes déclenchent le cycle. Les conteneurs à défilement disposent d'un répartiteur intégré qui envoie les deltas capturés lors des gestes dans le système. C'est pourquoi la plupart des cas d'utilisation de la personnalisation du défilement imbriqué impliquent d'utiliser NestedScrollConnection
au lieu d'un répartiteur, afin de réagir aux deltas existants plutôt que d'en envoyer de nouveaux.
Pour en savoir plus, consultez NestedScrollDispatcherSample
.
Redimensionner une image lors du défilement
Lorsque l'utilisateur fait défiler l'écran, vous pouvez créer un effet visuel dynamique dans lequel l'image change de taille en fonction de la position de défilement.
Redimensionner une image en fonction de la position de défilement
Cet extrait montre comment redimensionner une image dans un LazyColumn
en fonction de la position de défilement vertical. L'image se réduit lorsque l'utilisateur fait défiler la page vers le bas et se développe lorsqu'il la fait défiler vers le haut, tout en restant dans les limites de taille minimale et maximale définies:
@Composable fun ImageResizeOnScrollExample( modifier: Modifier = Modifier, maxImageSize: Dp = 300.dp, minImageSize: Dp = 100.dp ) { var currentImageSize by remember { mutableStateOf(maxImageSize) } var imageScale by remember { mutableFloatStateOf(1f) } val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Calculate the change in image size based on scroll delta val delta = available.y val newImageSize = currentImageSize + delta.dp val previousImageSize = currentImageSize // Constrain the image size within the allowed bounds currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize) val consumed = currentImageSize - previousImageSize // Calculate the scale for the image imageScale = currentImageSize / maxImageSize // Return the consumed scroll amount return Offset(0f, consumed.value) } } } Box(Modifier.nestedScroll(nestedScrollConnection)) { LazyColumn( Modifier .fillMaxWidth() .padding(15.dp) .offset { IntOffset(0, currentImageSize.roundToPx()) } ) { // Placeholder list items items(100, key = { it }) { Text( text = "Item: $it", style = MaterialTheme.typography.bodyLarge ) } } Image( painter = ColorPainter(Color.Red), contentDescription = "Red color image", Modifier .size(maxImageSize) .align(Alignment.TopCenter) .graphicsLayer { scaleX = imageScale scaleY = imageScale // Center the image vertically as it scales translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f } ) } }
Points clés concernant le code
- Ce code utilise un
NestedScrollConnection
pour intercepter les événements de défilement. onPreScroll
calcule la modification de la taille de l'image en fonction du delta de défilement.- La variable d'état
currentImageSize
stocke la taille actuelle de l'image, limitée entreminImageSize
etmaxImageSize. imageScale
, qui dérive decurrentImageSize
. - Les décalages
LazyColumn
sont basés surcurrentImageSize
. Image
utilise un modificateurgraphicsLayer
pour appliquer l'échelle calculée.- Le
translationY
dans legraphicsLayer
garantit que l'image reste centrée verticalement lors de la mise à l'échelle.
Résultat
L'extrait de code précédent génère un effet d'image de mise à l'échelle lors du défilement:
Interopérabilité du défilement imbriqué
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.
View
parent coopérant 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é.
View
parent non coopérant 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.
Recommandations personnalisées
- Remarque : Le texte du lien s'affiche lorsque JavaScript est désactivé
- Comprendre les gestes
- Migrer
CoordinatorLayout
vers Compose - Utiliser les vues dans Compose