В статье рассмотрим разработку пользовательского интерфейса Android-приложения с использованием современной библиотеки для создания UI, а именно Jetpack Compose. Минимум “воды”, максимум полезной информации.
Навигация по циклу статей:
-
Часть 1 – Прототипирование
-
Часть 2 – UI (Вы здесь)
-
Часть 3 – Бизнес-логика (В разработке)
Создаем структуру проекта:
Представим, что у нас ещё не создан проект, а Android Studio запустилась впервые.
Шаг 1. Выбираем шаблон “Empty Compose Activity”:
Первая строка, второй столбец, называется “Empty Activity”, по центру красивый шестиугольник 😉
Шаг 2. Выбираем название приложения (1), название пакета (2), месторасположение проекта (3) и минимально поддерживаемую версию Android (4):
В поле (1) записываем “My Tech Calculator”, в поле (2) записываем “my.tech.calculator”, а поля 3 и 4 оставляем стандартными.
Стоит остановится на этом шаге и разобрать каждое поле отдельно:
-
Application name – название приложения, которое увидит пользователь на экране смартфона;
-
Package name – уникальный идентификатор приложения, состоящий из названия компании и названия приложения, разделенных знаком “.” (точка);
-
Save location – местоположение проекта на Вашем компьютере;
-
Minimum SDK – минимально поддерживаемая версия Android, т.е. ниже этой версии пользователи не смогут установить приложение.
По готовности нажимаем на кнопку “Finish” в нижнем левом углу диалогового окна.
Шаг 3. После окончания генерации шаблонного проекта и загрузки стандартных библиотек приступаем к формированию структуры проекта:
Domain-модуль опустили, т.к. в данном варианте он избыточен
Опытные разработчики сходятся во мнении, что не следует в простом проекте делать многомодульность, абстрактные классы и другие изысканные архитектурные решения, поскольку они лишь усложняют разработку и увеличивают время на реализацию.
Следуя этим рекомендациям сформируем структуру нашего проекта:
-
base – хранит все файлы, связанные с архитектурой приложения;
-
ui – хранит все файлы, связанные с интерфейсом приложения;
-
theme – файлы, связанные с дизайн-системой приложения;
-
components – кастомные UI-элементы;
-
-
screens – файлы, описывающие экраны приложения;
-
-
data – хранит все файлы, связанные с получением и хранением данных;
-
datasource – классы, предоставляющие доступ к источникам данным;
-
repository – классы, использующие источники данных для одной конкретной задачи;
-
-
utils – хранит все вспомогательные файлы для работы приложения.
Остановимся на данном этапе проработки и перейдем к следующему шагу.
Проектируем дизайн-систему:
Благодаря дизайн-системе скорость создания экранов значительно увеличивается, код становится более читабельнее, а количество потенциальных ошибок при сопровождении приложения сокращается.
Шаг 1. Редактируем стандартную Material-тему приложения. Она генерируется автоматически при создании шаблонного проекта и находится по пути "ui/theme"
.
В этой папке содержится 3 файла, ответственные за цветовую схему, тему и текстовые стили
Шаг 1.1. В файле Color.kt
меняем шаблонный код на следующий:
// Цвета для светлой темы val LightBackground = Color(0xFFC6C6C6) val LightSurface = Color(0xFFF2F2F2) val LightPrimaryColor = Color(0xFF575757) val LightSecondaryColor = Color(0xFFE1E1E1) val LightOnPrimaryColor = Color(0xFFFFFFFF) val LightOnSecondaryColor = Color(0xFF282828) val LightOnSurfaceColor = Color(0xFF282828) // Цвета для темной темы val DarkBackground = Color(0xFF333333) val DarkSurface = Color(0xFF212121) val DarkPrimaryColor = Color(0xFF323232) val DarkSecondaryColor = Color(0xFF535353) val DarkOnPrimaryColor = Color(0xFFFFFFFF) val DarkOnSecondaryColor = Color(0xFFFFFFFF) val DarkOnSurfaceColor = Color(0xFFFFFFFF)
Примечание: Цвета мы взяли из цветовой палитры, рассмотренной в предыдущей статье.
Шаг 1.2. В файле Type.kt
меняем код на:
val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 30.sp ), titleLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 36.sp ) )
Поскольку у нас довольно простой интерфейс, стиль "bodyLarge"
будем использовать для кнопок и введенного пользователем мат. выражения, а стиль "titleLarge"
для результата вычисления мат. выражения.
Шаг 1.3. В шаблонном коде файла Theme.kt
присутствует поддержка пользовательской цветовой схемы из Material 3. Мы хотим сохранить уникальный стиль приложения, поэтому удалим эту фичу и обновим цветовую схему:
private val DarkColorScheme = darkColorScheme( primary = DarkPrimaryColor, onPrimary = DarkOnPrimaryColor, secondary = DarkSecondaryColor, onSecondary = DarkOnSecondaryColor, background = DarkBackground, surface = DarkSurface, onSurface = DarkOnSurfaceColor ) private val LightColorScheme = lightColorScheme( primary = LightPrimaryColor, onPrimary = LightOnPrimaryColor, secondary = LightSecondaryColor, onSecondary = LightOnSecondaryColor, background = LightBackground, surface = LightSurface, onSurface = LightOnSurfaceColor ) @Composable fun MyTechCalculatorTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { val colorScheme = when { darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) }
Шаг 2. Создаем первый кастомный UI-элемент в рамках дизайн-системы. Рассмотрим его визуальное представление для светлой и темной темы:
Кнопка переключения темы
Предварительно стоит перечислить “best practices” по созданию кастомных UI-элементов:
-
Согласно примерам из официальных библиотек первым параметром в Composable-функции следует использовать
Modifier
. -
Функциональный параметр
onClick
(обработчик клика по UI-элементу) следует добавлять последним, если нет функционального параметраcontent
(отвечает за расположение дочерних UI-элементов внутри родителя). -
Для Composable-функции, помеченной аннотацией
@Preview
и отвечающей за предпросмотр UI-элемента, следует добавлять модификатор видимостиprivate
.
Перейдем к реализации UI-элемента:
@Composable fun JetSwitchButton( modifier: Modifier = Modifier, isChecked: Boolean = false, onValueChange: (Boolean) -> Unit ) { val iconId = if (isChecked) R.drawable.ic_day else R.drawable.ic_moon Row( modifier = modifier .wrapContentWidth() .background( MaterialTheme.colorScheme.secondary, RoundedCornerShape(topStart = 16.dp, bottomEnd = 16.dp) ) .clip( RoundedCornerShape(topStart = 16.dp, bottomEnd = 16.dp) ) .clickable(onClick = { onValueChange.invoke(!isChecked) }), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .padding(horizontal = 8.dp, vertical = 4.dp) .size(48.dp, 24.dp) .background(MaterialTheme.colorScheme.onSecondary, RoundedCornerShape(16.dp)), contentAlignment = Alignment.CenterStart ) { Box( modifier = Modifier .padding(horizontal = 8.dp) .size(14.dp) .background(MaterialTheme.colorScheme.secondary, CircleShape) ) } Icon( modifier = Modifier.padding(horizontal = 8.dp), imageVector = ImageVector.vectorResource(id = iconId), contentDescription = "", tint = MaterialTheme.colorScheme.onSecondary ) } } @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable private fun ShowPreview() { MyTechCalculatorTheme { Row { JetSwitchButton( modifier = Modifier .fillMaxWidth() .height(32.dp), isChecked = true, {} ) } } } @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ShowPreview2() { MyTechCalculatorTheme { Row { JetSwitchButton( modifier = Modifier .fillMaxWidth() .height(32.dp), isChecked = true, {} ) } } }
Примечание: Особое внимание следует уделить названию кастомного UI-элемента. Обычно оно формируется по следующему шаблону – "Jet{ComponentName}"
, где “Jet” является сокращением от слова “Jetpack”.
При названии компонента стоит отталкиваться от стандартных названий в Jetpack Compose – Card, Button, Icon и т.д.
Например:
-
JetImageLoader()
– элемент для загрузки изображения; -
JetRatingBar()
– элемент, отображающий пятизвездочный рейтинг; -
JetEditorLayout()
– макет для редактора объекта.
Если префикс “Jet” кажется не слишком уникальным, можно добавить после него еще один префикс – сокращение компании или проекта. Такой вариант позволит производить навигацию по дизайн-системе ещё более эффективно.
Шаг 3. Перейдем к следующему, уже основному, кастомному UI-элементу. Также рассмотрим его визуальное представление для светлой и темной темы:
Скругленная кнопка
Продолжим перечислять “best practices” в рамках применения Jetpack Compose:
-
При большом количестве параметров одинакового предназначения их следует выносить в отдельный
@Immutable
класс для ухода от лишних рекомпозиций. -
Любые цвета, используемые в UI-элементах, следует брать напрямую из
MaterialTheme
, а не создавать их в коде (внутри Composable-функции). -
При переопределении Composable-функций для кастомных UI-элементов следует сохранять порядок одинаковых параметров.
Рассмотрим реализацию UI-элемента с применением рассмотренных выше практик:
@Composable fun JetRoundedButton( modifier: Modifier = Modifier, text: String, // отображаем обычный текст buttonColors: JetRoundedButtonColors, onClick: () -> Unit ) { Box( modifier = modifier .clip(CircleShape) .background(buttonColors.containerColor(), CircleShape) .innerShadow( shape = CircleShape, color = buttonColors.shadowContainerColor(), blur = 4.dp, offsetY = 4.dp, offsetX = 0.dp, spread = 0.dp ) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { Text( text = text, style = MaterialTheme.typography.bodyLarge, color = buttonColors.contentColor() ) } } @Composable fun JetRoundedButton( modifier: Modifier = Modifier, text: AnnotatedString, // отображаем текст с форматированием, например, x^y buttonColors: JetRoundedButtonColors, onClick: () -> Unit ) { Box( modifier = modifier .clip(CircleShape) .background(buttonColors.containerColor(), CircleShape) .innerShadow( shape = CircleShape, color = buttonColors.shadowContainerColor(), blur = 4.dp, offsetY = 4.dp, offsetX = 0.dp, spread = 0.dp ) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { Text( text = text, style = MaterialTheme.typography.bodyLarge, color = buttonColors.contentColor() ) } } @Composable fun JetRoundedButton( modifier: Modifier = Modifier, @DrawableRes iconId: Int, // отображаем векторную иконку buttonColors: JetRoundedButtonColors, onClick: () -> Unit ) { Box( modifier = modifier .clip(CircleShape) .background(buttonColors.containerColor(), CircleShape) .innerShadow( shape = CircleShape, color = buttonColors.shadowContainerColor(), blur = 4.dp, offsetY = 4.dp, offsetX = 0.dp, spread = 0.dp ) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { Icon( imageVector = ImageVector.vectorResource(iconId), contentDescription = null, tint = buttonColors.contentColor() ) } } object JetRoundedButtonDefaults { @Composable fun numberButtonColors( containerColor: Color = MaterialTheme.colorScheme.primary, contentColor: Color = MaterialTheme.colorScheme.onPrimary, shadowContainerColor: Color = Color.Black.copy(alpha = 0.6f), ): JetRoundedButtonColors = JetRoundedButtonColors( containerColor = containerColor, contentColor = contentColor, shadowContainerColor = shadowContainerColor ) @Composable fun operationButtonColors( containerColor: Color = MaterialTheme.colorScheme.secondary, contentColor: Color = MaterialTheme.colorScheme.onSecondary, shadowContainerColor: Color = Color.Black.copy(alpha = 0.6f), ): JetRoundedButtonColors = JetRoundedButtonColors( containerColor = containerColor, contentColor = contentColor, shadowContainerColor = shadowContainerColor ) } @Immutable class JetRoundedButtonColors internal constructor( private val containerColor: Color, private val contentColor: Color, private val shadowContainerColor: Color ) { @Composable internal fun containerColor(): Color { return containerColor } @Composable internal fun contentColor(): Color { return contentColor } @Composable internal fun shadowContainerColor(): Color { return shadowContainerColor } override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || other !is JetRoundedButtonColors) return false if (containerColor != other.containerColor) return false if (contentColor != other.contentColor) return false if (shadowContainerColor != other.shadowContainerColor) return false return true } override fun hashCode(): Int { var result = containerColor.hashCode() result = 31 * result + contentColor.hashCode() result = 31 * result + shadowContainerColor.hashCode() return result } }
В качестве элемента отображения могут выступать – текст, стилизованный текст, а также векторная иконка, поэтому разработаны три реализации Composable-функции JetRoundedTextButton()
.
Поскольку заранее известно, что видов скругленной кнопки может быть два – для мат. операций и для чисел (не только, но всё же), то для сокращения времени на кастомизацию UI-элемента создали отдельный объект JetRoundedButtonDefaults
, содержащий готовые стили для этих видов кнопок.
Для хранения стиля кнопок также создали отдельный Immutable-класс JetRoundedButtonColors
. К достоинствам этого решения можно отнести:
-
Удобство кастомизации UI-элемента;
-
Отсутствие лишних рекомпозиций;
-
Отсутствие “утечек памяти”.
Разберем последнее утверждение подробнее: Выше мы уже рассматривали, что цвета стоит брать напрямую из MaterialTheme
, а не создавать их внутри Composable-функции. Это связано с тем, что CompositionLocalProvider
, хранящий цветовую схему из MaterialTheme
, позволяет к ней обращаться из любой вложенной Composable-функци, в то время как создание объектов типа Color
внутри Composable-функции происходит при каждой рекомпозиции, чем вызывает лишние выделение памяти.
В рассмотренном выше коде используется кастомная реализация для создания внутренних теней UI-элемента –innerShadow()
от Kappdev:
fun Modifier.innerShadow( shape: Shape, color: Color, blur: Dp, offsetY: Dp, offsetX: Dp, spread: Dp ) = drawWithContent { drawContent() // Rendering the content val rect = Rect(Offset.Zero, size) val paint = Paint().apply { this.color = color this.isAntiAlias = true } val shadowOutline = shape.createOutline(size, layoutDirection, this) drawIntoCanvas { canvas -> // Save the current layer. canvas.saveLayer(rect, paint) // Draw the first layer of the shadow. canvas.drawOutline(shadowOutline, paint) // Convert the paint to a FrameworkPaint. val frameworkPaint = paint.asFrameworkPaint() // Set xfermode to DST_OUT to create the inner shadow effect. frameworkPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) // Apply blur if specified. if (blur.toPx() > 0) { frameworkPaint.maskFilter = BlurMaskFilter(blur.toPx(), BlurMaskFilter.Blur.NORMAL) } // Change paint color to black for the inner shadow. paint.color = Color.Black // Calculate offsets considering spread. val spreadOffsetX = offsetX.toPx() + if (offsetX.toPx() < 0) -spread.toPx() else spread.toPx() val spreadOffsetY = offsetY.toPx() + if (offsetY.toPx() < 0) -spread.toPx() else spread.toPx() // Move the canvas to specific offsets. canvas.translate(spreadOffsetX, spreadOffsetY) // Draw the second layer of the shadow. canvas.drawOutline(shadowOutline, paint) // Restore the canvas to its original state. canvas.restore() } }
Её следует разместить в модуле “utils”, создав файл ComposeExt.kt
.
Собираем User Interface:
Используя разработанную выше дизайн-систему реализуем UI для нашего единственного и неповторимого экрана:
Вспоминаем как экран выглядит 😉
В папке “screens” создаем подпапку “home”, а в ней структуру, согласно архитектуре MVI:
-
models
-
HomeEvent.kt – события от пользователя
-
HomeAction.kt – действия системы
-
HomeViewState.kt – состояние экрана
-
-
views
-
HomeViewInit.kt
-
-
HomeScreen.kt
-
HomeViewModel.kt
Такая структура позволяет разделить данные, представления и бизнес-логику, при этом всё находится в рамках одного модуля "home"
, а не разделено по отдельным модуля "models, views, viewmodels"
в рамках всего проекта.
Обобщая, рассмотренная выше структура сокращает временные затраты при внесении изменений в рамках одного модуля.
Теперь приступим к заполнению созданных выше файлов:
Шаг 1. При взаимодействии с приложением пользователь может:
-
Изменить тему приложения – светлая / темная;
-
Изменить математическое выражение – добавить число, мат. операцию или скобки;
-
Вычислить математическое выражение;
-
Удалить последний введенный символ;
-
Очистить введенное математическое выражение.
На основании этой информации заполним файл HomeEvent
:
sealed class HomeEvent { data class ChangeTheme(val newValue: Boolean) : HomeEvent() data class ChangeExpression(val newValue: ExpressionItem) : HomeEvent() data object CalculateExpression : HomeEvent() data object RemoveLastSymbol : HomeEvent() data object ClearExpression : HomeEvent() }
Мы используем sealed класс, позволяющий создать ограниченную иерархию классов. Одно из главных преимуществ этого решения – отсутствие исключения ClassCastException
при проверке элемента типа HomeEvent
, поскольку на этапе компиляции известны все наследники sealed класса. Подробнее рассмотрим этот момент в следующей части, посвященной бизнес-логике.
Шаг 2. Информацию об ошибке при вычислении математического выражения мы можем вывести в текстовое поле, а значит действий системы (отображение диалогового окна, закрытие экрана и т.п.) не требуется.
Таким образом, оставляем sealed класс в файле HomeAction
пустым:
sealed class HomeAction { }
Шаг 3. Как можно было заметить на шаге 1, в событии ChangeExpression
мы использовали переменную типа ExpressionItem
. Это сделано для того, чтобы убрать однотипные события пользователя, сгруппировав их в один класс:
sealed class ExpressionItem(val type: ExpressionItemType, val value: String) { // Математические операции data object OperationMul: ExpressionItem(ExpressionItemType.Operation, "*") data object OperationDiv: ExpressionItem(ExpressionItemType.Operation, "/") data object OperationPlus: ExpressionItem(ExpressionItemType.Operation, "+") data object OperationMinus: ExpressionItem(ExpressionItemType.Operation, "-") data object OperationSqrt: ExpressionItem(ExpressionItemType.Operation, "√") data object OperationSqr: ExpressionItem(ExpressionItemType.Operation, "^") data object OperationPercent: ExpressionItem(ExpressionItemType.Operation, "%") // Круглые скобки data object LeftBracket: ExpressionItem(ExpressionItemType.Bracket, "(") data object RightBracket: ExpressionItem(ExpressionItemType.Bracket, ")") // Числа от 0 до 9, а также "." data object Value0: ExpressionItem(ExpressionItemType.Value, "0") data object Value1: ExpressionItem(ExpressionItemType.Value, "1") data object Value2: ExpressionItem(ExpressionItemType.Value, "2") data object Value3: ExpressionItem(ExpressionItemType.Value, "3") data object Value4: ExpressionItem(ExpressionItemType.Value, "4") data object Value5: ExpressionItem(ExpressionItemType.Value, "5") data object Value6: ExpressionItem(ExpressionItemType.Value, "6") data object Value7: ExpressionItem(ExpressionItemType.Value, "7") data object Value8: ExpressionItem(ExpressionItemType.Value, "8") data object Value9: ExpressionItem(ExpressionItemType.Value, "9") data object ValuePoint: ExpressionItem(ExpressionItemType.Value, ".") // Используется при инициализации мат. выражения data object None: ExpressionItem(ExpressionItemType.Empty, "") companion object { fun convertToExpression(value: String): ExpressionItem { return when(value){ OperationMul.value -> OperationMul OperationDiv.value -> OperationDiv OperationPlus.value -> OperationPlus OperationMinus.value -> OperationMinus OperationSqrt.value -> OperationSqrt OperationSqr.value -> OperationSqr OperationPercent.value -> OperationPercent LeftBracket.value -> LeftBracket RightBracket.value -> RightBracket None.value -> None Value0.value -> Value0 Value1.value -> Value1 Value2.value -> Value2 Value3.value -> Value3 Value4.value -> Value4 Value5.value -> Value5 Value6.value -> Value6 Value7.value -> Value7 Value8.value -> Value8 Value9.value -> Value9 ValuePoint.value -> ValuePoint else -> throw Exception("Not found ExpressionItem with value") } } } } sealed class ExpressionItemType{ data object Operation: ExpressionItemType() data object Bracket: ExpressionItemType() data object Value: ExpressionItemType() data object Empty: ExpressionItemType() }
Шаг 4. Поскольку у нас нет загрузки данных с удаленного сервера или локальной базы данных, мы можем обойтись одним единым состоянием экрана. В этом случае используется data class
, а не sealed class.
К хранимым в рамках экрана данным относятся:
-
Математическое выражение – строковое значение, содержащее все введенные пользователем символы;
-
Результат вычисления мат. выражения – строковое значение;
-
Тип активной темы – логическое значения, где
true
– темная тема, аfalse
– светлая тема.
В итоге, в файл HomeViewState
запишем следующий код:
data class HomeViewState( val displayExpression: StringBuilder = StringBuilder(), // хранит текущее мат. выражение val privateExpression: StringBuilder = StringBuilder(), // хранит все предыдущие результаты + текущее мат. выражение val currentExpressionItem: ExpressionItem = ExpressionItem.None, // используется для предотвращения бесконечной последовательности мат.операций val expressionResult: String = "", // хранит результат текущего мат. выражения val isDarkTheme: Boolean = false )
Мы используем StringBuilder
для формирования математического выражения (вместо String), поскольку это более эффективно с точки зрения использования вычислительных ресурсов.
Шаг 5. Так как состояние экрана у нас одно, представление будет также одно. Обычно его название формируется по следующему шаблону – "{ScreenName}ViewInit"
.
Добавим следующий код для файла HomeViewInit
:
@Composable fun HomeViewInit( viewState: HomeViewState, onChangeTheme: (Boolean) -> Unit, onChangeExpression: (ExpressionItem) -> Unit, onClearExpression: () -> Unit, onRemoveLastSymbol: () -> Unit, onCalculateExpression: () -> Unit ) { val scrollState = rememberScrollState() Column( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { Box( modifier = Modifier .padding(horizontal = 24.dp, vertical = 24.dp) .fillMaxWidth() .height(208.dp) .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp)) ) { Column( modifier = Modifier .verticalScroll(scrollState) .padding(start = 32.dp, end = 64.dp, top = 32.dp, bottom = 16.dp) .fillMaxSize() .align(Alignment.BottomCenter) ) { Text( modifier = Modifier.fillMaxWidth(), text = viewState.displayExpression.toString(), textAlign = TextAlign.End, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge ) Text( modifier = Modifier.fillMaxWidth(), text = if (viewState.expressionResult.isEmpty()) "0" else "=${viewState.expressionResult}", textAlign = TextAlign.End, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleLarge ) } JetSwitchButton( modifier = Modifier.align(Alignment.TopStart), isChecked = false, onValueChange = onChangeTheme ) } Row( modifier = Modifier .padding(horizontal = 24.dp, vertical = 12.dp) .fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween ) { Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { JetRoundedButton(modifier = Modifier.size(64.dp), text = "C", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onClearExpression.invoke() }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "√", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationSqrt) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "1", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value1) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "4", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value4) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "7", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value7) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = ".", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.ValuePoint) }) } Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { JetRoundedButton(modifier = Modifier.size(64.dp), text = "(", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.LeftBracket) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "%", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationPercent) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "2", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value2) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "5", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value5) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "8", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value8) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "0", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value0) }) } Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { JetRoundedButton(modifier = Modifier.size(64.dp), text = ")", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.RightBracket) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = buildAnnotatedString { append("x") withStyle( SpanStyle( baselineShift = BaselineShift.Superscript, fontSize = 16.sp, fontWeight = FontWeight.Medium, color = Color.White ) ) { append("y") } }, buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationSqr) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "3", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value3) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "6", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value6) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "9", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value9) }) JetRoundedButton(modifier = Modifier.size(64.dp), iconId = R.drawable.ic_backspace, buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onRemoveLastSymbol.invoke() }) } Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { JetRoundedButton(modifier = Modifier.size(64.dp), text = "×", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationMul) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "÷", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationDiv) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "+", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationPlus) }) JetRoundedButton(modifier = Modifier.size(64.dp), text = "-", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationMinus) }) JetRoundedButton(modifier = Modifier.size(64.dp, 144.dp), text = "=", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onCalculateExpression.invoke() }) } } } } // Предпросмотр UI для светлой темы @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable private fun ShowPreview() { MyTechCalculatorTheme { HomeViewInit(viewState = HomeViewState(), {}, {}, {}, {}, {}) } } // Предпросмотр UI для темной темы @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ShowPreview2() { MyTechCalculatorTheme { HomeViewInit(viewState = HomeViewState(), {}, {}, {}, {}, {}) } }
При реализации палитры кнопок мы использовали Row+Column, а не ConstraintLayout. На это есть две причины:
-
Скорость отрисовки в Jetpack Compose не зависит от вложенности элементов;
-
В Compose Multiplatform ещё нет ConstraintLayout 😉
Важное примечание: Стоит отметить, что текущая реализация UI имеет один недостаток – на планшетах и на ПК, если говорим о мультиплатформе, кнопки будут неэффективно расположены как по горизонтали, так и по вертикали. Т.е. кнопки сохранят исходный размер, а между ними будет значительный промежуток, что усложнит взаимодействие пользователя с приложением.
Пример “поехавшей” верстки на планшете
Решить эту проблему можно с помощью реализации альтернативной верстки под планшеты, которая будет выбираться при ширине экрана устройства больше 400dp
.
Рассмотрим пример реализации:
BoxWithConstraints( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { // UI для планшетов if (this.maxWidth > 400.dp) { val marginBetweenElements = 16.dp val elementWidth = this.maxWidth / 4 - marginBetweenElements val elementHeight = this.maxHeight / 6 - marginBetweenElements Row( modifier = Modifier .padding(horizontal = 24.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(marginBetweenElements) ) { Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(marginBetweenElements) ) { JetRoundedTextButton(modifier = Modifier.size(elementWidth, elementHeight), text = "C", buttonColors = JetRoundedButtonDefaults.operationButtonColors(), onClick = { onClearExpression.invoke() }) JetRoundedTextButton(modifier = Modifier.size(elementWidth, elementHeight), text = "√", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.OperationSqrt) }) JetRoundedTextButton(modifier = Modifier.size(elementWidth, elementHeight), text = "1", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value1) }) JetRoundedTextButton(modifier = Modifier.size(elementWidth, elementHeight), text = "4", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value4) }) JetRoundedTextButton(modifier = Modifier.size(elementWidth, elementHeight), text = "7", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.Value7) }) JetRoundedTextButton(modifier = Modifier.size(elementWidth, elementHeight), text = ".", buttonColors = JetRoundedButtonDefaults.numberButtonColors(), onClick = { onChangeExpression.invoke(ExpressionItem.ValuePoint) }) } /* ... */ } } else { /* UI для смартфонов*/ } }
Примечание: Мы использовали элемент BoxWithConstraints()
, который измеряет свои размеры относительно родителя, и предоставляет доступ к этой информации для дочерних UI-компонентов.
В результате адаптации UI примет следующий вид:
Пример адаптации UI на планшете
Примечание: Рассмотренный вариант адаптации UI под разные типы устройств не является финальным и идеально реализованным (всегда есть что улучшить), в статье лишь делается акцент на наличии такой проблемы.
Шаг 6. Внесём изменения в файл HomeScreen
:
@Composable fun HomeScreen() { HomeViewInit( viewState = HomeViewState(), onChangeTheme = { }, onChangeExpression = { }, onCalculateExpression = { }, onClearExpression = { }, onRemoveLastSymbol = { } ) }
Примечание: Поскольку бизнес-логика ещё не реализована, оставим его в таком виде. Подробнее разберем и допишем реализацию в следующей статье.
Шаг 7. Обновим код в MainActivity, добавив отображение разработанного нами экрана:
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyTechCalculatorTheme { HomeScreen() } } } }
На этом разработка UI завершена. Можно запустить эмулятор Android и протестировать 😉
А где посмотреть исходники?
Ссылка на репозиторий: https://github.com/alekseyHunter/compose-tech-calculator
Если у Вас будут идеи по улучшению UI или предложения по новому функционалу, смело отправляйте Pull Request 😉 Для его рассмотрения автором рекомендуется оставить комментарии к этой статье с ссылкой на PR.
Полезные статьи других авторов по Jetpack Compose на Хабре:
-
Введение в Jetpack Compose: https://habr.com/ru/news/734876/
-
Управление состоянием в Compose: https://habr.com/ru/companies/otus/articles/656231/
-
Осознанная оптимизация Compose: https://habr.com/ru/companies/ozontech/articles/742854/
В следующей статье:
Рассмотрим реализацию бизнес-логики, а именно – создадим ViewModel, реализуем лексический анализатор, а также модуль вычисления математического выражения на основе метода рекурсивного спуска.
Алексей Охотниченко
Автор Телеграм-канала “Android для Junior’ов” (@android_for_juniors)