Списки в Compose для Wear OS


Списки позволяют пользователям выбирать элементы из набора вариантов на устройствах Wear OS.

На многих устройствах Wear OS используются круглые экраны, поэтому элементы списка, расположенные вверху и внизу экрана, могут быть плохо видны. Поэтому в Compose для Wear OS включена версия класса LazyColumn под названием TransformingLazyColumn, которая поддерживает масштабирование и морфинг. Когда объекты перемещаются к краям, они уменьшаются и становятся менее заметными.

Чтобы применить рекомендуемые эффекты масштабирования и прокрутки:

  1. Используйте Modifier.transformedHeight, чтобы функция Compose могла рассчитать изменение высоты при прокрутке элемента на экране.
  2. Используйте transformation = SurfaceTransformation(transformationSpec), чтобы применить визуальные эффекты, в том числе уменьшить размер содержимого элемента.
  3. Используйте специальный параметр TransformationSpec для компонентов, которые не принимают параметр transformation, например Text.

На анимации ниже показано, как элемент списка масштабируется и меняет форму при приближении к верхней и нижней частям экрана:

В следующем фрагменте кода показано, как создать список с помощью макета TransformingLazyColumn, чтобы контент хорошо выглядел на экранах Wear OS разных размеров.

В этом фрагменте также показано, как использовать модификатор minimumVerticalContentPadding, который нужно задать для элементов списка, чтобы применить правильные отступы сверху и снизу.

Чтобы показать индикатор прокрутки, поделите columnState на ScreenScaffold и TransformingLazyColumn:

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(
    scrollState = columnState
) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding
    ) {
        item {
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text(text = "Header")
            }
        }
        // ... other items
        item {
            Button(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec),
                onClick = { /* ... */ },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Build,
                        contentDescription = "build",
                    )
                },
            ) {
                Text(
                    text = "Build",
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

Как добавить эффект "щелчок и бросок"

Привязка гарантирует, что после того, как пользователь завершит прокрутку или жест смахивания, список остановится на элементе, расположенном точно в определенной точке, обычно в центре экрана. На круглых экранах, где объекты масштабируются и трансформируются при перемещении от центра, прикрепление особенно полезно, поскольку позволяет убедиться, что самый важный объект остается полностью видимым и читаемым в оптимальной области просмотра.

Чтобы добавить поведение "прилипание и бросок", задайте для параметра flingBehavior значение TransformingLazyColumnDefaults.snapFlingBehavior(columnState). Установите значение rotaryScrollableBehavior, чтобы оно совпадало с RotaryScrollableDefaults.snapBehavior(columnState). Это обеспечит единообразное взаимодействие при использовании физической заводной головки или окантовки.

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        flingBehavior = TransformingLazyColumnDefaults.snapFlingBehavior(columnState),
        rotaryScrollableBehavior = RotaryScrollableDefaults.snapBehavior(columnState)
    ) {
        // ...
        // ...
    }
}

Обратный макет

По умолчанию прокручиваемый список прикрепляется к верхнему краю. Если пользователь прокрутил стандартный список до конца и в него был добавлен новый элемент, список сохранит текущий элемент в поле зрения пользователя. Например, если пользователь смотрит на объект 10 в нижней части экрана и добавляется объект 11, то фокус остается на объекте 10, а объект 11 появляется за пределами экрана ниже текущего представления.

В таких случаях, как приложения для обмена сообщениями или журналы в реальном времени, такое поведение обычно нежелательно. Когда появляются новые элементы, пользователи обычно хотят сразу увидеть их, если они уже прокрутили список до конца. Если одновременно поступает много объектов, список должен прокручиваться до самого последнего объекта внизу (это означает, что некоторые промежуточные объекты могут не отображаться, если пользователь не прокрутит список вверх).

Чтобы реализовать эти варианты использования, в TransformingLazyColumn можно изменить макет, задав значение reverseLayout = true. В этом случае список будет привязан не к верхнему, а к нижнему краю.

Для удобства при выборе параметра reverseLayout = true также меняется визуальный порядок элементов и направление прокрутки:

  • Элементы располагаются снизу вверх, то есть элемент с индексом 0 находится внизу экрана.
  • При прокрутке вверх показываются объекты с более высокими индексами.

Чтобы добавить поведение прилипания и перелистывания вместе с обратным макетом, можно объединить flingBehavior и rotaryScrollableBehavior, как показано в следующем фрагменте кода:

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding,
        reverseLayout = true,
        modifier = Modifier.fillMaxWidth()
    ) {
        items(10) { index ->
            Button(
                label = {
                    Text(
                        text = "Item ${index + 1}"
                    )
                },
                onClick = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            )
        }
        item {
            // With reverseLayout = true, the last item declared appears at the top.
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text("Header")
            }
        }
    }
}

На изображениях ниже показана разница между обычным и обратным списком:

Компонент TransformingLazyColumn с обычной разметкой, в котором элемент "Товар 1" находится вверху, а остальные элементы расположены в порядке возрастания.
Рисунок 1. Стандартный макет списка, в котором контент заполняется сверху вниз.
Компонент TransformingLazyColumn с обратным макетом, в котором элемент 1 находится внизу, а остальные элементы расположены в порядке убывания вверх.
Рисунок 2. Обратный порядок элементов списка, когда контент заполняется снизу вверх.

Кнопки на краях списков

В Material 3 можно добавить EdgeButton – кнопку, прижатую к краю внизу списка. Однако не добавляйте его как элемент в TransformingLazyColumn, а используйте слот edgeButton в ScreenScaffold.

Использование слота edgeButton гарантирует, что кнопка будет правильно размещена в нижней части экрана и будет корректно работать при прокрутке списка.

В приведенном ниже фрагменте кода показано, как реализовать EdgeButton:

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
    scrollState = columnState,
    edgeButton = {
        EdgeButton(
            onClick = { /* TODO */ },
            modifier = Modifier.scrollable(
                columnState,
                orientation = Orientation.Vertical,
                reverseDirection = true,
                // Apply overscroll to the EdgeButton for proper scrolling behavior.
                overscrollEffect = rememberOverscrollEffect(),
            )
        ) {
            Text("More")
        }
    }
) { contentPadding ->
    TransformingLazyColumn(
        contentPadding = contentPadding,
        state = columnState,
    ) {
        // ...
        // ...
    }
}

Пролистывание для показа в списках

Компонент SwipeToReveal позволяет получить доступ к действиям для элемента списка, например Card или Chip, с помощью жестов. Проведя пальцем по экрану, вы увидите одну или две кнопки действий, например "Удалить" или "Ещё".

При использовании SwipeToReveal в TransformingLazyColumn следуйте приведенным ниже рекомендациям.

  • Сброс при прокрутке. Когда пользователь прокручивает список, все открытые элементы возвращаются в исходное состояние.
  • Единая высота. Установите высоту командных кнопок, соответствующую высоте внутреннего элемента, который можно пролистывать (Button или Card), чтобы обеспечить единообразный вид.
  • Преобразование контейнера. Примените модификатор transformedHeight и transformationSpec к самому компоненту SwipeToReveal.
  • Не применяйте двойное преобразование. Не используйте модификаторы transformedHeight или transformation для внутреннего объекта, который можно сдвинуть (карточки или кнопки внутри контейнера SwipeToReveal).

Собственные функции, создающие интерфейс, в списках

При создании собственных компонентов поверхности для TransformingLazyColumn следуйте приведенным ниже рекомендациям, чтобы обеспечить плавное масштабирование, затухание и преобразование composable-функции у краев экрана:

  • Параметр SurfaceTransformation. Принимает необязательный параметр SurfaceTransformation (по умолчанию null), соответствующий стандартным компонентам Wear Compose Material 3, таким как Card и Button. Это позволяет вызывающим абонентам в TransformingLazyColumn пропускать SurfaceTransformation(transformationSpec), но при этом компонент работает нормально вне списка.
  • Примените Modifier.transformedHeight первым в коде вызова. При размещении пользовательской composable-функции в TransformingLazyColumn передайте Modifier.transformedHeight(this, transformationSpec) в качестве первого модификатора в цепочке модификаторов кода вызова. Пока SurfaceTransformation применяет визуальные эффекты масштабирования и затухания, transformedHeight критически важен для того, чтобы макет списка пересчитал высоту элемента по мере его уменьшения.
  • Примените слои преобразования, вызывающую функцию modifier и функцию рисования в указанном порядке.
    1. Слой преобразования контейнера. Начните цепочку модификаторов корневого контейнера с Modifier.graphicsLayer и applyContainerTransformation(), чтобы фон и контент отрисовывались в масштабированном и наклоненном пространстве координат.
    2. Вызывающий объект modifier – применить параметр modifier, переданный вызывающим объектом (включая Modifier.transformedHeight), до внутреннего определения размера или отступов.
    3. Обрезать фигуру, если преобразование не выполняется. Если transformation имеет значение null, примените Modifier.clip(shape) до отрисовки фона. Художник, возвращенный createContainerPainter(), обрезается по форме, а обычный художник – нет, поэтому без этого фон рисуется с квадратными углами за пределами списка.
    4. Художник, изменяющий фон: нарисуйте фон внутри слоя контейнера, используя Modifier.drawBehind и художника, созданного из createContainerPainter().
    5. Слой преобразования контента. Примените второй элемент Modifier.graphicsLayer с applyContentTransformation() и clip к форме контейнера, чтобы внутренний контент исчезал раньше, когда он приближается к рамке.

В следующем фрагменте кода показано, как реализовать собственную composable-функцию BoardingPassCard, которая применяет эти преобразования в указанном порядке:

@Composable
fun BoardingPassCard(
    flightNumber: String,
    origin: String,
    destination: String,
    gate: String,
    seat: String,
    departureTime: String,
    modifier: Modifier = Modifier,
    transformation: SurfaceTransformation? = null,
    shape: Shape = RoundedCornerShape(18.dp),
    statusBadge: @Composable () -> Unit = {}
) {
    // 1. Create morphing container painter
    val backgroundPainter = ColorPainter(MaterialTheme.colorScheme.surfaceContainer)
    val finalPainter = if (transformation != null) {
        remember(transformation, backgroundPainter, shape) {
            transformation.createContainerPainter(backgroundPainter, shape, border = null)
        }
    } else {
        backgroundPainter
    }

    Column(
        modifier = Modifier
            // 2a. Container layer: Scales, fades, and tilts the whole card surface
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        transformation.run { applyContainerTransformation() }
                    }
                } else Modifier
            )
            // 2b. Caller modifier: Includes Modifier.transformedHeight in a list
            .then(modifier)
            .fillMaxWidth()
            // 2c. Shape clip: Only needed without a transformation, because the
            // painter from createContainerPainter clips itself to the shape
            .then(if (transformation == null) Modifier.clip(shape) else Modifier)
            // 2d. Morphing background: Drawn inside the transformed container layer
            .drawBehind {
                with(finalPainter) {
                    draw(size)
                }
            }
            // 2e. Content layer: Fades content earlier and clips children to shape
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        this.shape = shape
                        this.clip = true
                        transformation.run { applyContentTransformation() }
                    }
                } else Modifier
            )
            .padding(horizontal = 14.dp, vertical = 10.dp)
    ) {
        // Card content goes here
    }
}
Рисунок 3. Пользовательская composable-функция посадочного талона, которая трансформируется при прокрутке списка.

Затем вы можете использовать BoardingPassCard внутри TransformingLazyColumn, передав Modifier.transformedHeight в качестве первого модификатора вместе с SurfaceTransformation(transformationSpec):

@Composable
fun BoardingPassListSample(flights: List<FlightInfo>) {
    val listState = rememberTransformingLazyColumnState()
    val transformationSpec = rememberTransformationSpec()


    ScreenScaffold(scrollState = listState) { contentPadding ->
        TransformingLazyColumn(
            state = listState,
            contentPadding = contentPadding,
            modifier = Modifier.fillMaxSize()
        ) {
            items(flights.size) { index ->
                val flight = flights[index]
                BoardingPassCard(
                    flightNumber = flight.number,
                    origin = flight.origin,
                    destination = flight.destination,
                    gate = flight.gate,
                    seat = flight.seat,
                    departureTime = flight.time,
                    modifier = Modifier
                        .transformedHeight(this, transformationSpec)
                        .minimumVerticalContentPadding(
                            CardDefaults.minimumVerticalListContentPadding
                        ),
                    transformation = SurfaceTransformation(transformationSpec)
                )
            }
        }
    }
}