
Здравствуй, дорогой читатель!
В этой статье рассмотрим поэтапную разработку дизайн-системы и UI для Android-приложения "Калькулятор", используя библиотеку Jetpack Compose. Начнём с создания проекта и закончим запуском приложения на эмуляторе.
Навигация по циклу статей:
Часть 2 - UI (Вы здесь)
Часть 3 - Бизнес-логика (В разработке)
Создаем структуру проекта:
Представим, что у нас ещё не создан проект, а Android Studio запустилась впервые.
Шаг 1. Выбираем шаблон "Empty Compose Activity":

Шаг 2. Выбираем название приложения (1), название пакета (2), месторасположение проекта (3) и минимально поддерживаемую версию Android (4):

Стоит остановится на этом шаге и разобрать каждое поле отдельно:
Application name - название приложения, которое увидит пользователь на экране смартфона;
Package name - уникальный идентификатор приложения, состоящий из названия компании и названия приложения, разделенных знаком "." (точка);
Save location - местоположение проекта на Вашем компьютере;
Minimum SDK - минимально поддерживаемая версия Android, т.е. ниже этой версии пользователи не смогут установить приложение.
По готовности нажимаем на кнопку "Finish" в нижнем левом углу диалогового окна.
Шаг 3. После окончания генерации шаблонного проекта и загрузки стандартных библиотек приступаем к формированию структуры проекта:

Опытные разработчики сходятся во мнении, что не следует в простом проекте делать многомодульность, абстрактные классы и другие изысканные архитектурные решения, поскольку они лишь усложняют разработку и увеличивают время на реализацию.
Следуя этим рекомендациям сформируем структуру нашего проекта:
base - хранит все файлы, связанные с архитектурой приложения;
ui - хранит все файлы, связанные с интерфейсом приложения;
theme - файлы, связанные с дизайн-системой приложения;
components - кастомные UI-элементы;
screens - файлы, описывающие экраны приложения;
data - хранит все файлы, связанные с получением и хранением данных;
datasource - классы, предоставляющие доступ к источникам данным;
repository - классы, использующие источники данных для одной конкретной задачи;
utils - хранит все вспомогательные файлы для работы приложения.
Остановимся на данном этапе проработки и перейдем к следующему шагу.
Проектируем дизайн-систему:
Благодаря дизайн-системе скорость создания экранов значительно увеличивается, код становится более читабельнее, а количество потенциальных ошибок при сопровождении приложения сокращается.
Шаг 1. Редактируем стандартную Material-тему приложения. Она генерируется автоматически при создании шаблонного проекта и находится по пути "ui/theme".

Шаг 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 ) } } // Предпросмотр UI для светлой темы @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) // Предпросмотр UI для темной темы @Preview(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-функции JetRoundedButton().
Поскольку заранее известно, что видов скругленной кнопки может быть два - для мат. операций и для чисел (не только, но всё же), то для сокращения времени на кастомизацию UI-элемента создали отдельный объект JetRoundedButtonDefaults, содержащий готовые стили для этих видов кнопок.
Для хранения стиля кнопок также создали отдельный Immutable-класс JetRoundedButtonColors. К достоинствам этого решения можно отнести:
Удобство кастомизации UI-элемента;
Отсутствие лишних рекомпозиций;
Отсутствие "утечек памяти".
Разберем последнее утверждение подробнее: Выше мы уже рассматривали, что цвета стоит брать напрямую из MaterialTheme, а не создавать их внутри Composable-функции. Это связано с тем, что CompositionLocalProvider, хранящий цветовую схему из MaterialTheme, позволяет к ней обращаться из любой вложенной Composable-функции, в то время как создание объектов типа Color внутри Composable-функции происходит при каждой рекомпозиции, чем вызывает лишнее выделение памяти.
В рассмотренном выше коде используется кастомная реализация innerShadow() для создания внутренних теней UI-элемента - от 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.kt:
Посмотреть реализацию
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.kt запишем следующий код:
Посмотреть реализацию
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.kt:
Посмотреть реализацию
@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) // Предпросмотр 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 под разные типы устройств не является финальным и идеально реализованным (всегда есть что улучшить), в статье лишь делается акцент на наличии такой проблемы.
Шаг 6. Внесём изменения в файл HomeScreen.kt:
Посмотреть реализацию
@Composable fun HomeScreen() { HomeViewInit( viewState = HomeViewState(), onChangeTheme = { }, onChangeExpression = { }, onCalculateExpression = { }, onClearExpression = { }, onRemoveLastSymbol = { } ) }
Примечание: Поскольку бизнес-логика ещё не реализована, оставим его в таком виде. Подробнее разберем и допишем реализацию в следующей статье.
Шаг 7. Обновим код в MainActivity.kt, добавив отображение разработанного нами экрана:
Посмотреть реализацию
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, реализуем лексический анализатор, а также модуль вычисления математического выражения на основе метода рекурсивного спуска.
Благодарю за внимание!

