Библиотека Jetpack Compose значительно изменила подход к разработке нативных приложений и позволила декларативно описывать в коде интерфейсы, которые зависят от состояния и автоматически отслеживают его изменение. Но долгое время ее применимость ограничивалась платформой Android для телефонов и планшетов, а затем (благодаря разработкам JetBrains) стало возможным использовать реактивный стиль разработки для создания десктопных и веб-приложений. Но все еще нельзя было создавать приложения для умных часов, работающих над вариантом платформы Android - WearOS. В июле 2022 года команда разработки Android предложила первую стабильную версию Compose for WearOS, а в начале декабря вышло обновление библиотеки версии 1.1 с новыми возможностями по настройке пользовательского интерфейса и дополнительными компонентами. В этой статье мы сделаем несложную игру для WearOS с использованием Compose.
Реактивные интерфейсы в Compose определяются как функция с аннотацией @Composable, в которой формируется композиция из других компонентов (входящих в состав библиотеки компонентов или собственных). Для Jetpack Compose for WearOS список компонентов можно найти в документации. Для более подробного погружения в разработку реактивных приложений на WearOS рассмотрим простой пример.
Создадим проект с внешним build.gradle:
buildscript {
ext {
compose_version = '1.3.2'
wear_compose_version = '1.1.0'
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.3.1' apply false
id 'com.android.library' version '7.3.1' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}
Для модуля проект определяется как Android-приложение, но с добавлением зависимостей:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'tech.dzolotov.wearosgame'
compileSdk 33
defaultConfig {
applicationId "tech.dzolotov.wearosgame"
minSdk 30
targetSdk 33
versionCode 1
versionName "1.0"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.1.1'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.wear.compose:compose-material:$wear_compose_version"
implementation "androidx.wear.compose:compose-navigation:$wear_compose_version"
implementation "androidx.wear.compose:compose-foundation:$wear_compose_version"
implementation 'androidx.activity:activity-compose:1.6.1'
}
В AndroidManifest.xml нужно добавить в тэг manifest:
<uses-feature android:name="android.hardware.type.watch" />
Для описания экрана необходимо определить композицию компонентов в setContent. Compostable-функции могут использоваться для размещения нескольких компонентов (например, вертикально в Column, горизонтально в Row, а также с наложением в Box), а также для представления содержания (Text, Button, Card и другие). При необходимости настройки компонента необходимо использовать модификаторы, с помощью которых можно изменять фон, отступы, возможность прокрутки, действия при нажатии и другие. Например, для отображения текста и кнопки реализация может выглядеть следующим образом:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
verticalArrangement = Arrangement.Center
) {
Text(
"Hello, WearOS",
color = Color.Green,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Card(onClick = {
Log.i(this::class.java.simpleName, "Tapped")
}, modifier = Modifier.padding(top = 8.dp)) {
Text(
"Tap me",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
}
}
}
Выполним сборку и установку приложения на эмулятор или реальное устройство:
./gradlew installDebug
При разработке можно определять состояние внутри Composable, или получать его из внешнего источника (например, из LiveData, Flow или MutableState). Например, мы можем добавить возможность подсчета количества нажатий, для этого дополнительно выделим изменяемую часть в отдельный компонент:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Content()
}
}
}
@Composable
fun Counter(i: Int) {
Text(
"Counter: $i",
color = Color.Green,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
@Composable
fun IncrementButton(onPressed: () -> Unit) {
Card(onClick = {
onPressed()
}, modifier = Modifier.padding(top = 8.dp)) {
Text(
"Tap me",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
@Composable
fun Content() {
var counter by remember { mutableStateOf(0) }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
verticalArrangement = Arrangement.Center
) {
Counter(i = counter)
IncrementButton {
counter++
}
}
}
При локальном создании состояния необходимо обернуть его в remember { }, поскольку функция будет вызываться повторно при каждом изменении состояния и надо явным образом обозначить, что значение должно быть инициализировано только однажды и сохраняться между перезапусками. Аналогично можно сделать прокручиваемую область с размером, больше чем область экрана, для этого необходимо использовать LazyColumn и элементы в item/items.
@Composable
fun Content() {
var counter by remember { mutableStateOf(0) }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background),
verticalArrangement = Arrangement.Center
) {
item {
Counter(i = counter)
}
item {
IncrementButton {
counter++
}
}
val list = MutableList(10) { "Task $it " }
this.items(items = list) {
Text(it)
}
}
}
Но при такой реализации размещение будет выполняться в предположении, что экран часов является квадратным. Однако многие модели имеют круглый экран и для них изображение будет выглядеть не очень хорошо:
Для корректировки смещения необходимо использовать специальный компонент ScalingLazyColumn и определять смещение с учетом положения элемента на экране, например следующим образом:
@Composable
fun Content() {
var counter by remember { mutableStateOf(0) }
val itemSpacing = 8.dp
val scrollOffset = 0
val state = rememberScalingLazyListState(
initialCenterItemIndex = 1,
initialCenterItemScrollOffset = scrollOffset
)
ScalingLazyColumn(
modifier = Modifier.fillMaxWidth(),
anchorType = ScalingLazyListAnchorType.ItemCenter,
verticalArrangement = Arrangement.spacedBy(itemSpacing),
state = state,
autoCentering = AutoCenteringParams(itemOffset = scrollOffset)
) {
item {
Counter(i = counter)
}
item {
IncrementButton {
counter++
}
}
val list = MutableList(10) { "Task $it " }
this.items(items = list) {
Text(it)
}
}
}
Теперь результат выглядит значительно лучше:
Для определения вида экрана (круглый или квадратный) можно использовать свойства конфигурации, которую мы можем получить с использованием подхода LocalComposition для передачи объектов через дерево Composable.
val isRound = LocalConfiguration.current.isScreenRound
Аналогично могут быть получен контекст Activity (LocalContext), информация о плотности (LocalDensity), стили оформления (определяются через MaterialTheme и извлекаются из LocalContentColor, LocalTextStyle), менеджеры управления буфером обмена (LocalClipboardManager), фокусом (LocalFocusManager), режимами ввода (LocalInputModeManager) и другие.
Добавим логотип приложения над счетчиком:
@Composable
fun Logo() {
Image(painter = painterResource(id = R.drawable.demo), contentDescription = "Logo")
}
ScalingLazyColumn(...) {
item {
Logo()
}
//...
}
При необходимости создания нескольких экранов мы можем использовать библиотеку навигации, которая для WearOS реализована иначе, чем для обычного Compose. Прежде всего добавим библиотеку в gradle dependencies:
implementation "androidx.wear.compose:compose-navigation:$wear_compose_version"
Теперь необходимо создать контроллер навигации (будет использоваться в дальнейшем для переключения между экранами):
@Composable
fun Navigation() {
val navController = rememberSwipeDismissableNavController()
SwipeDismissableNavHost(
navController = navController,
startDestination = "main"
) {
composable("main") {
Content(navController = navController)
}
composable("game") {
GameScreen()
}
}
}
@Composable
fun GameScreen() {
Box(modifier = Modifier.fillMaxSize().background(Color.Blue))
}
@Composable
fun StartGameButton(onPressed: () -> Unit) {
Card(onClick = {
onPressed()
}, modifier = Modifier.padding(top = 8.dp)) {
Text(
"Start game",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
}
//...
ScalingLazyColumn(...) {
//...
item {
StartGameButton {
navController.navigate("game")
}
}
//...
Теперь, когда мы можем делать многостраничные приложения добавим взаимодействие с пользователем, для этого создадим простую реализацию игры в 15. Состояние игрового поля будем хранить отдельно (правильно было бы использовать ViewModel, но для упрощения разместим состояние в глобальной переменной). Для инициализации поля обернем вызов функции в LaunchedEffect, который позволяет выполнить однократное действие (или перезапустить его при изменении аргумента). Также добавим обработку жестов для перемещения чисел.
var gameField = mutableStateOf(mutableListOf<Int>())
fun initializeGame() {
val field = mutableListOf<Int>()
field.addAll(List(16) { 0 })
for (i in 1..15) {
var position = Random.nextInt(16)
while (field[position] != 0) {
position = Random.nextInt(16)
}
field[position] = i
}
gameField.value = field
}
Для описания экрана будем использовать разметку сеткой, но добавим дополнительные модификаторы для корректного отображения на круглом экране:
@Composable
fun GameScreen() {
LaunchedEffect(Unit) {
initializeGame()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Blue)
) {
val size = LocalConfiguration.current.screenWidthDp
LazyVerticalGrid(
columns = GridCells.Fixed(4),
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
println(change)
if (dragAmount>size/32) {
toRight()
}
if (dragAmount<-size/32) {
toLeft()
}
}
detectVerticalDragGestures { change, dragAmount ->
if (dragAmount>size/32) {
toBottom()
}
if (dragAmount<-size/32) {
toTop()
}
}
}
.padding((size / 16).dp)
.safeDrawingPadding()
.fillMaxSize(),
content = {
items(items = gameField.value) {
if (it != 0) {
Text(text = it.toString(), textAlign = TextAlign.Center)
}
}
})
}
}
При перемещении чисел нужно будет следить за тем, что в state нужно будет записать новый список (обычная мутация внутри списка не будет отслеживаться как изменение состояния):
fun toLeft() {
val pos = gameField.value.indexOf(0)
if (pos % 4 < 3) {
//shift next
val fieldCopy = mutableListOf<Int>()
fieldCopy.addAll(gameField.value)
fieldCopy[pos] = fieldCopy[pos+1]
fieldCopy[pos+1] = 0
gameField.value = fieldCopy
}
}
Далее необходимо доработать логику перемещения чисел и проверку завершения игры с переходом на экран с поздравлениями. Исходные тексты игры размещены на github в репозитории https://github.com/dzolotov/wearos15.
Во второй части статьи мы рассмотрим как можно работать с отдельными пикселями и делать более сложные визуализации на часах, об использовании сетевых запросов и взаимодействию с системными сервисами для работы с уведомлениями WearOS, получению информации о местоположении, заряде батареи и информацией о физической активности.
Статья подготовлена в преддверии старта курса Android Developer. Professional. Также приглашаем всех желающих на бесплатный урок по теме: "Профайлинг ui". Узнать подробнее о курсе и зарегистрироваться на бесплатный урок можно по ссылке ниже.