Наконец, настал момент, когда не нужно собирать самостоятельно Android Studio, чтобы попробовать новый декларативный UI framework для Android. Jetpack Compose стал доступен в виде первого Dev Preview в Maven-репозитории Google. С такой новости началось моё утро понедельника. И сразу же возникло желание посмотреть, что из себя представляет набор инструментов, который так ждали.
Своё знакомство я решил начать сразу с попытки внедрения в pet-project, опубликованный в Google Play. Тем более, в нем давно хотелось сделать страницу “О приложении”. В этой статье я расскажу об основных компонентах и этапах подключения Compose:
- Подключение зависимостей
- Темы и стили. Интеграция с существующими в проекте.
- Accessibility и UI-тесты.
- Основные компоненты и аналоги наследников View.
- Работа со State.
Подключение зависимостей
Для начала я обновил студию c 3.5 до 3.5.1 (зря), добавил базовые зависимости. Полный список можно увидеть в статье Кирилла.
//корневой build.gradle
ext.compose_version= '0.1.0-dev01’
//build.gradle модуля
dependencies{
...
implementation "androidx.compose:compose-runtime:$compose_version"
kapt "androidx.compose:compose-compiler:$compose_version"
implementation "androidx.ui:ui-layout:$compose_version"
implementation "androidx.ui:ui-android-text:$compose_version"
implementation "androidx.ui:ui-text:$compose_version"
implementation "androidx.ui:ui-material:$compose_version"
}
И затем пытался всё это собрать из-за разъехавшихся версий Firebase. После чего столкнулся уже с препятствиями Compose:
app/src/main/AndroidManifest.xml Error: uses-sdk:minSdkVersion 16 cannot be smaller than version 21 declared in library [androidx.ui:ui-layout:0.1.0-dev01] .../ui-layout-0.1.0-dev01/AndroidManifest.xml as the library might be using APIs not available in 16 Suggestion: use a compatible library with a minSdk of at most 16, or increase this project's minSdk version to at least 21, or use tools:overrideLibrary="androidx.ui.layout" to force usage (may lead to runtime failures)
Да, Compose оказался доступен только с minSdk 21 (Lolipop). Возможно, это временная мера, но от него ожидали поддержки более ранних версий операционки.
Но и это не всё. Compose работает на Reflection, вместо Kotlin Compiler Plugin, как это было заявлено ранее, например, тут. Поэтому, чтобы всё завелось, нужно добавить в зависимости ещё и Kotlin Reflect:
implementation "org.jetbrains.kotlin:kotlin-reflect"
Ну и на сладкое. В Compose dp реализован как extension функции для Int, Long, Float, которые помечены ключевым словом inline. Это может вызвать новую ошибку компиляции:
Cannot inline bytecode built with JVM target 1.8 into bytecode that is being built with JVM target 1.6. Please specify proper '-jvm-target' option
* https://stackoverflow.com/questions/48988778/cannot-inline-bytecode-built-with-jvm-target-1-8-into-bytecode-that-is-being-bui
Для решения нужно явно прописать версию JVM для Kotlin:
android {
…
kotlinOptions {
jvmTarget = "1.8"
}
}
Вот, кажется, и всё. Намного легче, чем собирать свою студию)
Попробуем запустить Hello World (тоже из статьи Кирилла, но, в отличие от него, добавим Compose внутрь Fragment). Layout для фрагмента представляет собой пустой FrameLayout.
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val fragmentView = inflater.inflate(R.layout.fragment_about, container, false)
(fragmentView as ViewGroup).setContent {
Hello("Jetpack Compose")
}
return fragmentView
}
@Composable
fun Hello(name: String) = MaterialTheme {
FlexColumn {
inflexible {
// Item height will be equal content height
TopAppBar<MenuItem>( // App Bar with title
title = { Text("Jetpack Compose Sample") }
)
}
expanded(1F) {
// occupy whole empty space in the Column
Center {
// Center content
Text("Hello $name!") // Text label
}
}
}
}
Запускаем, получается следующий экран:
Из-за того, что Composable использует Material-тему по умолчанию, мы получили фиолетовый AppBar. Ну и, как и ожидалось, она совсем не согласуется с темной темой приложения:
Попробуем это решить.
Темы и стили. Интеграция с существующими в проекте.
Для того, чтобы использовать существующие стили внутри Composable, передадим их внутрь конструктора MaterialTheme:
@Composable
fun Hello(name: String) = MaterialTheme(colors = MaterialColors(
primary = resolveColor(context, R.attr.colorPrimary, MaterialColors().primary),
secondary = resolveColor(context, R.attr.colorSecondary, MaterialColors().secondary),
onBackground = resolveColor(context, R.attr.textColor, MaterialColors().onBackground)
)){...}
Сама MaterialTheme состоит из двух частей: MaterialColors и MaterialTypography.
Для разрешения цветов я использовал обертку над стилями:
private fun resolveColor(context: Context?, @AttrRes attrRes: Int, colorDefault: Color) = context?.let { Color(resolveThemeAttr(it, attrRes).data.toLong()) }
?: colorDefault
private fun resolveThemeAttr(context: Context, @AttrRes attrRes: Int): TypedValue {
val theme = context.theme
val typedValue = TypedValue()
theme.resolveAttribute(attrRes, typedValue, true)
return typedValue
}
На данном этапе AppBar перекрасится в зеленый цвет. Но для перекраски текста нужно сделать еще одно действие:
Text("Hello $name!", style = TextStyle(color = +themeColor { onBackground }))
Тема к виджету применяется использованием операции унарного плюса. Мы еще увидим её при работе со State.
Теперь новый экран выглядит однородно с остальным приложением в обоих вариантах темы:
В источниках Compose нашел также файл DarkTheme.kt, функции из которого можно использовать для определения различных триггеров включения темной темы на Android P и 10.
Accessibility и UI-тесты.
Пока экран не начал разрастаться новыми элементами, давайте посмотрим, как он выглядит в Layout Inspector и со включенным отображением границ элементов в Dev Mode:
Здесь мы увидим FrameLayout, внутри которого только AndroidComposeView. Существующие инструменты для Accebility и UI-тестирования теперь больше не применимы? Возможно, вместо них теперь будет новая библиотека: androidx.ui:ui-test
.
Основные компоненты и аналоги наследников View.
Теперь попробуем сделать экран чуть более информативным. Для начала поменяем текст, добавим кнопку, ведущую на страницу приложения в Google Play, и картинку с логотипом. Сразу покажу код и что получилось:
@Composable
fun AboutScreen() = MaterialTheme(...) {
FlexColumn {
inflexible {
TopAppBar<MenuItem>(title = { Text(getString(R.string.about)) })
}
expanded(1F) {
VerticalScroller {
Column {
Image()
Title()
MyButton()
}
}
}
}
}
private fun Image() {
Center {
Padding(16.dp) {
Container(
constraints = DpConstraints(
minWidth = 96.dp,
minHeight = 96.dp
)
) {
imageFromResource(resources, R.drawable.ic_launcher)
}
}
}
}
private fun Title() {
Center {
Padding(16.dp) {
Text(getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME,
style = TextStyle(color = +themeColor { onBackground }))
}
}
}
private fun MyButton() {
Center {
Padding(16.dp) {
Button(getString(R.string.about_button), onClick = {
openAppInPlayStore()
})
}
}
}
Основные принципы композиции виджетов не изменились с момента первого появления исходников Compose.
Из интересного:
- Функции для отображения отдельных элементов не обязательно помечать аннотацией @Composable.
- Почти все свойства для виджетов превратились в отдельные виджеты (Center вместо android:gravity, Padding вместо android:margin, …)
- Отобразить картинку из drawables мне так и не удалось.
- У кнопки параметр onClick сделан не последним, из-за чего нельзя передать его как лямбду без явного указания названия, что казалось бы логичнее:
Button(“Text"){ openAppInPlayStore() }
Пройдемся теперь по основным существующим ViewGroup и попробуем найти аналоги в Compose.
Вместо FrameLayout можно использовать Stack. Тут всё просто: дочерние виджеты накладываются друг на друга и позиционируются в зависимости от используемой для вложения функции: aligned, positioned или expanded.
LinearLayout заменяется сразу двумя виджетами: Column и Row вместо использования параметра android:orientation. Они же, в свою очередь, содержат внутри себя FlexColumn и FlexRow с прослойкой функции inflexible над вложенным поддеревом. Ну а сами FlexColumn и FlexRow построены на Flex с параметром orientation = LayoutOrientation.Vertical
или Horizontal
.
Похожая иерархия у виджетов FlowColumn, FlowRow и Flow. Их основное отличие: если контент не помещается в один столбец или строку, рядом отрисуется следующий, и вложенные виджеты “перетекут” туда. Реальное предназначение для этих виджетов мне пока представить сложно.
Эффект ScrollView достигается помещением Column или Row внутрь VerticalScroller или HorizontalScroller. Оба они композируют внутри Scroller, передавая внутрь параметр isVertical = true
или false
.
В поисках аналога для ConstraintLayout или хотя бы RelativeLayout наткнулся на новый виджет Table. Попытался запустить пример кода у себя в приложении: DataTableSamples.kt. Но, как я не пытался упростить пример, сделать его работающим так и не получилось.
Работа со State
Одним из самых ожидаемых нововведений фреймворка является его готовность из коробки к использованию в однонаправленных архитектурах, построенных на основе единого состояния. И в этом предполагалось введение аннотации @Model для пометки классов, предоставляющих State для отрисовки UI.
Рассмотрим пример:
data class DialogVisibleModel(val visible: Boolean, val dismissPushed: Boolean = false)
...
@Composable
fun SideBySideAlertDialogSample() {
val openDialog = +state { DialogVisibleModel(true) }
Button(text = "Ok", onClick = { openDialog.value = DialogVisibleModel(true) })
if (openDialog.value.visible) {
AlertDialog(
onCloseRequest = {
// Because we are not setting openDialog.value to false here,
// the user can close this dialog only via one of the buttons we provide.
},
title = {
Text(text = "Title")
},
text = {
Text("This area typically contains the supportive text" +
" which presents the details regarding the Dialog's purpose.")
},
confirmButton = {
Button("Confirm", onClick = {
openDialog.value = DialogVisibleModel(false)
})
},
dismissButton = {
if (!openDialog.value.dismissPushed)
Button("Dismiss", onClick = {
openDialog.value = DialogVisibleModel(true, true)
})
else {
//hidden
}
},
buttonLayout = AlertDialogButtonLayout.SideBySide
)
}
}
Здесь создается дата-класс для модели стейта, при этом его не обязательно помечать аннотацией @Model.
Само исходное состояние создаётся внутри @Composable функции с использованием +state.
Видимость диалога определяется свойством visible из модели, полученной вызовом свойства value.
Этому свойству можно также задавать новый неизменяемый объект, как это происходит в onClick обеих кнопок. Первая скрывает саму себя, вторая — закрывает диалог. Диалог можно переоткрыть, нажав на кнопку Ok, определенную внутри той же @Composable функции.
При попытке вынести состояние вне этой функции возникает ошибка:
java.lang.IllegalStateException: Composition requires an active composition context.
Контекст можно получить, присвоив значение функции setContent{} в onCreateView, но как его использовать, например в Presenter или другом классе, отличном от Fragment или Activity, для изменения состояния – пока непонятно.
На этом завершим обзор новой библиотеки Jetpack Compose. Фреймворк архитектурно оправдывает своё название, заменяя всё наследование, которое так сильно доставляло неудобства в иерархии View, композицией. Пока остаётся слишком много вопросов о том, как будут реализованы аналоги более сложных ViewGroup, типа ConstraintLayout и RecyclerView; не хватает документации и превью.
Абсолютно понятно, что Compose не готов к применению даже в маленьких боевых приложениях.
Но это всего лишь первая версия Dev Preview. Будет интересно наблюдать за развитием концепции работы со State и библиотеками от комьюнити на основе Compose.
Если вы нашли более удачные примеры кода, или документации для кейсов, которые у меня не получилось завести – напишите пожалуйста в комментарии.