В статье рассмотрим разработку пользовательского интерфейса Android-приложения с использованием современной библиотеки для создания UI, а именно Jetpack Compose. Минимум “воды”, максимум полезной информации.

Навигация по циклу статей:

  • Часть 1 – Прототипирование

  • Часть 2 – UI (Вы здесь)

  • Часть 3 – Бизнес-логика (В разработке)

Создаем структуру проекта:

Представим, что у нас ещё не создан проект, а Android Studio запустилась впервые.

Шаг 1. Выбираем шаблон “Empty Compose Activity”:

Android-приложение на Compose с нуля: Часть 2 (UI)

Первая строка, второй столбец, называется “Empty Activity”, по центру красивый шестиугольник 😉

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

Android-приложение на Compose с нуля: Часть 2 (UI)

В поле (1) записываем “My Tech Calculator”, в поле (2) записываем “my.tech.calculator”, а поля 3 и 4 оставляем стандартными.

Стоит остановится на этом шаге и разобрать каждое поле отдельно:

  • Application name – название приложения, которое увидит пользователь на экране смартфона;

  • Package name – уникальный идентификатор приложения, состоящий из названия компании и названия приложения, разделенных знаком “.” (точка);

  • Save location – местоположение проекта на Вашем компьютере;

  • Minimum SDK – минимально поддерживаемая версия Android, т.е. ниже этой версии пользователи не смогут установить приложение.

По готовности нажимаем на кнопку “Finish” в нижнем левом углу диалогового окна.

Шаг 3. После окончания генерации шаблонного проекта и загрузки стандартных библиотек приступаем к формированию структуры проекта:

Android-приложение на Compose с нуля: Часть 2 (UI)

Domain-модуль опустили, т.к. в данном варианте он избыточен

Опытные разработчики сходятся во мнении, что не следует в простом проекте делать многомодульность, абстрактные классы и другие изысканные архитектурные решения, поскольку они лишь усложняют разработку и увеличивают время на реализацию.

Следуя этим рекомендациям сформируем структуру нашего проекта:

  • base – хранит все файлы, связанные с архитектурой приложения;

  • ui – хранит все файлы, связанные с интерфейсом приложения;

    • theme – файлы, связанные с дизайн-системой приложения;

      • components – кастомные UI-элементы;

    • screens – файлы, описывающие экраны приложения;

  • data – хранит все файлы, связанные с получением и хранением данных;

    • datasource – классы, предоставляющие доступ к источникам данным;

    • repository – классы, использующие источники данных для одной конкретной задачи;

  • utils – хранит все вспомогательные файлы для работы приложения.

Остановимся на данном этапе проработки и перейдем к следующему шагу.

Проектируем дизайн-систему:

Благодаря дизайн-системе скорость создания экранов значительно увеличивается, код становится более читабельнее, а количество потенциальных ошибок при сопровождении приложения сокращается.

Шаг 1. Редактируем стандартную Material-тему приложения. Она генерируется автоматически при создании шаблонного проекта и находится по пути "ui/theme".

Android-приложение на Compose с нуля: Часть 2 (UI)

В этой папке содержится 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-элемент в рамках дизайн-системы. Рассмотрим его визуальное представление для светлой и темной темы:

Android-приложение на Compose с нуля: Часть 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-элементу. Также рассмотрим его визуальное представление для светлой и темной темы:

Android-приложение на Compose с нуля: Часть 2 (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 для нашего единственного и неповторимого экрана:

Android-приложение на Compose с нуля: Часть 2 (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 имеет один недостаток – на планшетах и на ПК, если говорим о мультиплатформе, кнопки будут неэффективно расположены как по горизонтали, так и по вертикали. Т.е. кнопки сохранят исходный размер, а между ними будет значительный промежуток, что усложнит взаимодействие пользователя с приложением.

Android-приложение на Compose с нуля: Часть 2 (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 примет следующий вид:

Android-приложение на Compose с нуля: Часть 2 (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-приложение на Compose с нуля: Часть 2 (UI)

Алексей Охотниченко

Автор Телеграм-канала “Android для Junior’ов” (@android_for_juniors)