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