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

5392a738cd3493fd230c528c334d581f.png

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

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

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

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

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

Первая строка, второй столбец, называется

Первая строка, второй столбец, называется «Empty Activity», по центру красивый шестиугольник;)

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

В поле (1) записываем

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

В этой папке содержится 3 файла, ответственные за цветовую схему, тему и текстовые стили

В этой папке содержится 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 на планшете

Примечание: Рассмотренный вариант адаптации 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 на Хабре:

В следующей статье:

Рассмотрим реализацию бизнес-логики, а именно — создадим ViewModel, реализуем лексический анализатор, а также модуль вычисления математического выражения на основе метода рекурсивного спуска.

© Habrahabr.ru