Привет, меня зовут Саша, я Android-разработчик команды разработки мобильного приложения Банка РНКБ. Сегодня хочу поделиться своим опытом использования Compose.
В июле прошлого года Google анонсировал первую стабильную версию Jetpack Compose, а на момент написания статьи уже вышла версия 1.1. Несмотря на то, что использовать данный инструмент можно было задолго до фактического релиза, сейчас метаморфозы API завершились(хотя некоторые его части всё ещё помечены аннотацией @Experimental*Api). Сам Compose как инструмент для разработки теперь точно стал production ready (ну так обещают).
Философия построения интерфейса
В Jetpack Compose построение UI проходит непосредственно в коде. Но в отличие от традиционного для Android императивного способа (в коде), здесь используется декларативный подход. Думаю те, кто уже работал с фреймворками вроде React или Flutter, смогут начать использовать его практически сходу.
Единицей построения интерфейса является функция, помеченная аннотацией @Composable. Таким образом, построение интерфейса заключается в композиции таких функций. Создание кастомных вьюшек и их переиспользование становится намного проще и удобнее!
AndroidStudio предоставляет удобный тулинг, который позволяет сразу же посмотреть превью получившейся разметки. Для этого функция должна быть помечена аннотацией @Preview и должна иметь значения по умолчанию для всех параметров функции (Также можно задать данные с помощью @PreviewParameter). @Preview имеет набор параметров, которые позволяют настроить отображение. Например: widthDp, heightDp ─ задают ограничения вьюпорта, в котором будет отрендерен превью, device ─ задаёт размер вьюпорта в соответствии с конкретным устройством, showSystemUi ─ отвечает за рендеринг рамки вокруг разметки с app bar, status bar и bottom bar, locale ─ задаёт локаль для рендеринга. Кроме простого рендеринга, превью есть также возможность live edit литералов для строк, Int-ов, размерностей(Dp), цветов и Boolean. Также есть интерактивный режим, в котором можно проинспектировать работу анимаций и поведение UI в целом.
Создать проект с поддержкой Jetpack Compose можно выбрав Empty Compose Activity в визарде создания проекта.
Давайте попробуем вывести два текстовых элемента:
@Preview
@Composable
fun HelloCompose(
title: String = "Hello compose",
content: String = "Text from Jetpack compose",
) {
Text(text = title)
Text(text = content)
}
На вкладке Preview мы увидим следующее:
Скорее всего это не то, что хотелось бы увидеть. Так произошло потому, что для позиционирования элементов нужен какой-то контейнер. Есть три основных контейнера: Box, Column и Row. Box – самый простой контейнер, аналог FrameLayout, образует скоуп BoxScope и позиционирует элементы внутри себя с помощью модификатора BoxScope.align. Кстати, модификаторы – это то, что позволяет задавать различные атрибуты позиционирования и отображения для элемента. Давайте рассмотрим пример с использованием Box:
@Preview
@Composable
fun BoxSample() {
Box(
modifier = Modifier
.background(Color.Red)
.size(200.dp)
.padding(32.dp)
.background(Color.Green)
.padding(32.dp)
.background(Color.Blue)
)
}
В примере выше мы создали только один Box, но получили аж три квадрата разных цветов. Почему так получилось? Вся магия кроется в модификаторах. Причем порядок модификаторов важен: сначала установили красный фон и задали размер блока, далее мы установили отступы в 32dp и залили зеленым ─ теперь фон и содержимое отсчитываются от полученных рамок. Последующие отступы будут отсчитываться относительно текущих границ. Таким образом можно задать и margin, и padding, и border (хотя для этого случая есть соответствующий модификатор).
Column
По сути это аналог LinearLayout с вертикальной ориентацией. Таким образом все элементы внутри данного контейнера размещаются друг под другом. Здесь есть два основных параметра: horizontalAlignment: Alignment.Horizontal и verticalArrangement: Arrangement.Vertical. HorizontalAlignment определяет выравнивание вложенных элементов по горизонтали. По умолчанию Alignment.Start, что значит выравнивание по левому краю (либо по правому в right-to-left режиме).
VerticalArrangement определяет, как будут распределяться элементы по вертикали. По умолчанию Arrangement.Top, что означает расположение максимально близко во вертикальной оси. Также можно задать расположение по центру (Center), распределить свободное расстояние между элементами (SpaceAround, SpaceEvenly, SpaceBetween) либо задать фиксированное расстояние (spacedBy()).
@Preview(widthDp = 180, heightDp = 200)
@Composable
fun ColumnSample() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.background(Color.White),
) {
repeat(5) {
Box(
modifier = Modifier
.fillMaxWidth(fraction = 0.9f)
.height(36.dp)
.clip(shape = RoundedCornerShape(6.dp))
.background(Color.Gray)
)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
В примере выше используется стандартная функция Kotlin repeat. Таким образом мы можем просто создать список из нужного количества элементов пройдя в цикле.
Кроме того, Column дает своим потомкам модификатор fllMaxWidth(). C помощью параметра fraction мы захватываем 90% ширины родительского контейнера.
Здесь же встречается еще один модификатор – clip(). Как можно предположить, мы закругляем края с радиусом 6dp.
А ещё ниже есть компонент Spacer – простой заполнитель места.
Если нужен организовать скролл для Column, то нужно просто добавить модификатор verticalScroll(). Обязательным параметром является ScrollState, который можно получить с помощью rememberScrollState().
@Preview(widthDp = 320, heightDp = 600)
@Composable
fun ScrollableColumnSample() {
val scrollState = rememberScrollState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
.background(Color.White),
) {
repeat(20) { position ->
println("Build item at position $position")
Row {
MyListItem(
position = position,
color = Color.Gray,
)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
}
Row
В примере выше можно увидеть Row. Соответственно Row представляет собой строку, то есть размещает все элементы внутри себя горизонтально.
LazyColumn, LazyRow
Column и Row со скроллом это, конечно, хорошо, но данные компоненты создают все свои элементы сразу же, что не очень круто для больших или динамически подгружаемых списков. Здесь нужен какой-то аналог RecyclerView, который мог бы строить элементы по мере необходимости, и он есть – LazyColumn и LazyRow.
Эти компоненты создают скоуп LazyListScope, внутри которого можно добавить один или сразу несколько элементов. Кстати, скролл этим компонентам не нужен – он уже встроен.
@Preview(widthDp = 320, heightDp = 300)
@Composable
fun LazyColumnSample() {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.background(Color.White),
) {
items(100) { position ->
println("Build item at position $position")
Row {
MyListItem(
position = position,
color = Color.Gray,
)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@Preview(widthDp = 320, heightDp = 50)
@Composable
fun MyListItem(
position: Int = 0,
color: Color = Color.Gray,
) {
Row(
modifier = Modifier
.fillMaxWidth(fraction = 0.9f)
.padding(4.dp)
.height(42.dp)
) {
Box(
modifier = Modifier
.aspectRatio(1.0f, matchHeightConstraintsFirst = true)
.clip(CircleShape)
.background(color)
) {
Text(
text = position.toString(),
style = MaterialTheme.typography.subtitle1,
modifier = Modifier
.align(Alignment.Center)
)
}
Spacer(modifier = Modifier.width(6.dp))
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp)
.clip(shape = RoundedCornerShape(2.dp))
.background(color)
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth(fraction = 0.6f)
.height(8.dp)
.clip(shape = RoundedCornerShape(2.dp))
.background(color)
)
}
}
}
При запуске данного кода можно увидеть, что элементы теперь действительно создаются лениво. Ещё здесь используются стили. В Compose они создаются и наследуются программно.
Небольшой лайфхак: В некоторых компонентах, вроде LazyColumn и LazyRow, есть такой параметр как key.
Это позволяет задать ключ, который однозначно идентифицирует элемент списка, что позволяет провернуть некоторую оптимизацию: избежать рекопмозиции элементов, которые не изменились. Можно рассматривать как аналог DiffUtil.
BoxWithConstraints
Бонусом можно рассказать об еще одном контейнере – BoxWithConstraints.
Этот контейнер создает скоуп BoxWithConstraintsScope, который содержит ограничения контейнера: minWidth, maxWidth, minHeight, maxHeight. С помощью этого можно создавать адаптивную разметку, которая будет подстраиваться под доступный размер экрана.
В примере ниже сделаем блок, который будет занимать 60% высоты экрана.
@Preview(device = Devices.PIXEL_4)
@Composable
fun BoxWithConstraintsSample(
splitFraction: Float = 0.6f,
) {
BoxWithConstraints(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxSize()
) {
val offset = 24.dp
Image(
painter = painterResource(R.drawable.sample_mini),
contentScale = ContentScale.FillHeight,
contentDescription = stringResource(R.string.sample_description),
modifier = Modifier
.fillMaxWidth()
.height(maxHeight * splitFraction)
)
Column(
modifier = Modifier
.fillMaxSize()
.offset(y = maxHeight * splitFraction - offset)
.clip(shape = RoundedCornerShape(percent = 6))
.background(color = MaterialTheme.colors.background)
.padding(offset)
) {
Text(
text = stringResource(R.string.box_constraints_title),
style = MaterialTheme.typography.h6,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.box_constraints_content),
style = MaterialTheme.typography.body1,
)
}
}
}
Из новшеств, которые можно встретить в данном примере ─ использование ресурсов и изображений. Можно догадаться, что painterResource – это аналог Resources.getDrawable(), stringResource – аналог Resources.getString(). С помощью ContentScale.FillHeight в компоненте Image мы растянули изображение по ширине. Кроме того, возможны значения None,Crop, Fit, FillWidth, Inside, FillBounds. Работа этих типов масштабирования аналогична ImageView.ScaleType.
Обработка нажатий
Есть несколько способов обработки нажатий: с помощью модификаторов Modifier.clickable() и с помощью готовых кнопок material дизайна (Button, OutlinedButton, TextButton). Ниже представлен экран, который отображает количество нажатий на кнопку.
@Preview(widthDp = 300, heightDp = 600)
@Composable
fun ButtonSample() {
val counter = remember { mutableStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.White),
) {
Text(
text = "Counter value: ${counter.value}",
style = MaterialTheme.typography.h4,
)
Spacer(modifier = Modifier.height(12.dp))
Button(
modifier = Modifier
.align(Alignment.CenterHorizontally),
onClick = {
counter.value++
},
) {
Text("Increment", color = Color.White)
}
}
}
Сохранение состояния
Здесь мы подходим к еще одной концепции Compose: сохранение состояния. Вернее ранее мы уже использовали сохранения состояния, применяя rememberScrollState(). Поскольку дерево компонентов может перестраиваться, когда ему заблагорассудится, то использовать локальные переменные для хранения состояния не очень хорошая идея. Для того чтобы проинициализировать какую-то переменную состояния лишь единожды, используется функция remember(). Как правило, внутрь блока данной функции подается mutableStateOf(), который создает MutableState<T>, но по факту поместить туда можно что угодно. Блок функции remember() будет вызван только при первой композиции дерева элементов. Изменение значения MutableState вызовет перестроение дерева компонентов в скоупе. Таким образом компоненты будут перерисованы с новыми значениями.
@Preview(widthDp = 300, heightDp = 600)
@Composable
fun ButtonSample() {
val counter = remember { mutableStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.White),
) {
Text(
text = "Counter value: ${counter.value}",
style = MaterialTheme.typography.h4,
)
Spacer(modifier = Modifier.height(12.dp))
Button(
modifier = Modifier
.align(Alignment.CenterHorizontally),
onClick = {
counter.value++
},
) {
Text("Increment", color = Color.White)
}
}
}
State Hoisting
Хорошей практикой является написание таких компонентов, которые не имеют своего состояния, а принимают его извне. Это позволяет инкапсулировать логику компонента и сделать его легко переиспользуемым. Таким образом мы принимаем текущее состояние через параметры composable-функции, а обратную связь даем через лямбды, также полученные в качестве входных параметров.
Использование remember() хорошо подходит для сохранения состояния интерфейса, но у него есть недостаток – при изменении конфигурации Activity состояние будет потеряно.
Выходом из такого положения может послужить тот все тот же метод – использование ViewModel.
Мы можем использовать LiveData, StateFlow, преобразовывая их в State, используя функции-расширения observeAsState() и collectAsState() соответственно.
Для получения экземпляра ViewModel можно использовать функцию viewModel().
По умолчанию мы имеем LocalViewModelStoreOwner, что дает нам один экземпляр ViewModel в локальном скоупе.
Использование ViewModel
@ExperimentalMaterialApi
@Preview
@Composable
fun ScreenViewViewModelSample(
viewModel: SocialNetworksListViewModel = viewModel(),
) {
val scrollState = rememberScrollState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.social_networks_list_title)) },
)
},
) {
val socialNetworksState = viewModel.state.collectAsState()
val allSocialNetworks = socialNetworksState.value.allSocialNetworks
val favourite = socialNetworksState.value.favourite
Column(modifier = Modifier.verticalScroll(scrollState)) {
allSocialNetworks.forEach {
ListItem(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true),
) {
viewModel.setFavourite(it)
},
icon = {
SocialNetworkIcon(
text = it.name.take(1),
backgroundColor = Color(it.backgroundColorHex),
)
},
text = {
Text(it.name)
},
secondaryText = {
Text(it.url)
},
trailing = {
if (it == favourite) {
FavouriteIcon()
}
}
)
}
}
}
}
@Composable
private fun FavouriteIcon() {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = stringResource(R.string.favourite),
tint = Color.Red,
)
}
@Composable
private fun SocialNetworkIcon(
text: String = "A",
backgroundColor: Color,
) {
Box(
modifier = Modifier
.size(42.dp)
.clip(CircleShape)
.background(backgroundColor)
) {
Text(
text = text,
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
shadow = Shadow(
color = Color.DarkGray,
offset = Offset(6f, 4f),
blurRadius = 6f,
)
),
modifier = Modifier.align(Alignment.Center)
)
}
}
SocialNetworksListViewModel.kt
class SocialNetworksListViewModel : ViewModel() {
data class SocialNetworksListState(
val allSocialNetworks: List<SocialNetwork> = emptyList(),
val favourite: SocialNetwork? = null,
)
private val _state = MutableStateFlow(
SocialNetworksListState(
allSocialNetworks = listOf(
SocialNetwork("Facebook", "https://facebook.com", 0xFF4267B2L),
SocialNetwork("WhatsApp", "https://whatsapp.com/", 0xFF25D366L),
SocialNetwork("Instagram", "https://instagram.com/", 0xFFE1306CL),
SocialNetwork("Twitter", "https://twitter.com/", 0xFF1DA1F2L),
SocialNetwork("VK", "https://vk.com/", 0xFF4C75A3L),
SocialNetwork("Telegram", "https://telegram.org/", 0xFF0088CCL),
)
)
)
val state: StateFlow<SocialNetworksListState> = _state.asStateFlow()
fun setFavourite(socialNetwork: SocialNetwork) {
_state.tryEmit(
_state.value.copy(
favourite = socialNetwork
)
)
}
}
SocialNetwork.kt
data class SocialNetwork(
val name: String,
val url: String,
val backgroundColorHex: Long,
)
В примере выше есть еще один интересный компонент – Scaffold. Scaffold представляет собой швейцарский нож, который содержит все атрибуты Material экрана: TopBar, BottomBar, Drawer, Floating action button. Вам нужно только сконфигурировать эти элементы.
Анимация
Для завершения не хватает еще одного ингредиента 一 анимация. Compose содержит крутейший API для программного создания анимаций. Для его разбора нужно посвятить отдельную статью. Всё, что нужно в нашем примере 一 это изменить функцию FavouriteIcon().
@Preview
@Composable
private fun FavouriteIcon(
visible: Boolean = true,
) {
val scale by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing,
)
)
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = stringResource(R.string.favourite),
tint = Color.Red,
modifier = Modifier.scale(scale),
)
}
Property анимация в Compose очень проста. Для создания анимации нужно просто вызвать функцию animate*AsState(), где “*” может быть: Float, Dp, Size, Offset, Rect, Int, IntOffset, IntSize либо любой тип, для которого есть реализация TwoWayConverter. Параметр targetValue отвечает за конечное значение анимации, а animationSpec определяет тип кривой анимации.
Основные типы AnimationSpec:
TweenSpec – обычная анимация с задаваемой функцией интерполяции
SpringSpec – physics-based анимация
KeyframesSpec – анимация, основанная на ключевых кадрах
Наконец, получим следующую картинку:
Пришло время подвести итоги. Надеюсь, статья дает достаточно информации для старта использования Compose. Мы прошлись по основам, а на самом деле статья получилось довольно объемной. При этом по многим пунктам прошлись поверхностно.
Большая часть UI кода может быть переиспользована в Compose Desktop и Compose Web буквально методом copy-paste. Jetpack Compose имеет понятную документацию, поэтому более глубокое изучение не должно вызвать больших проблем.
Безусловно это очень крутой инструмент, который все больше будет использоваться в Android-разработке. Несмотря на все плюсы, можно столкнуться с некоторыми проблемами.
Использование Jetpack Compose требует использования Kotlin (хотя по статистике большинство Android приложений уже пишутся на Kotlin).
Нужно иметь минимальный SDK 21+ (кто-то еще поддерживает Android 4).
При попытке внедрить в существующий проект при компиляции начали вылезать фантомные ошибки Backend Internal error: Exception during IR lowering в классах, которые вообще каким образом не относятся к Compose экранам (довольно досадно, в теории с интероперабельностью все должно быть хорошо).
На этом все. Это наша первая статья на Хабре. Не кидайтесь камнями. Будем рады выслушать замечания и конструктивную критику.