Всем привет, я Android разработчик в компании Enaza подразделения Games, которая занимается дистрибуцией ключей для игр. В данном посте опишу опыт интеграции Jetpack Compose в существующий проект.
Проект: лаунчер мобильных игры по подписке.
Проект представляет из себя мобильное приложение написанное на Kotlin с использованием многомодульной архитектуры в качестве DI был использован Dagger Hilt, в качестве навигации был выбран frag-nav за свою гибкость открытия фрагментов из любой части приложения.
В данном опыте цель пере использовать дублирующиеся фрагменты дизайна интерфейса, а именно:
1. Карточка игры, использующаяся в личном кабинете в разделе игры, в поиске и каталоге
2. Экран активации промо кода, новый функционал, который зашел в приложение + он сам по себе не большой в верстке
Для начала нужно импортировать библиотеки compose через gradle
build.gradle.kts
dependencies {
implementation(Dependencies.AndroidX.core)
implementation(Dependencies.AndroidX.appCompat)
implementation(Dependencies.Google.material)
implementation(Dependencies.Compose.annotation)
implementation(Dependencies.Compose.composeConstraint)
implementation(Dependencies.Compose.composeMaterial)
implementation(Dependencies.Compose.composeUi)
implementation(Dependencies.Compose.composeUiTool)
implementation(Dependencies.Compose.composeUiToolPreview)
implementation(Dependencies.Compose.composeUiUtil)
implementation(Dependencies.AndroidX.compose)
implementation(Dependencies.Commons.codec)
implementation(Dependencies.Coil.coil)
implementation(Dependencies.Coil.coilCompose)
}
Dependencies.kt
object Dependencies {
object AndroidX {
const val core = "androidx.core:core-ktx:1.6.0"
const val appCompat = "androidx.appcompat:appcompat:1.3.0"
const val activity = "androidx.activity:activity-ktx:1.3.1"
const val constraintlayout = "androidx.constraintlayout:constraintlayout:1.1.3"
const val recyclerview = "androidx.recyclerview:recyclerview:1.2.1"
const val compose = "androidx.activity:activity-compose:1.4.0"
}
object Coil {
const val coil = "io.coil-kt:coil-compose:1.3.2"
const val coilCompose = "io.coil-kt:coil-compose:1.3.2"
}
object Compose {
const val annotation = "androidx.annotation:annotation:1.2.0"
const val composeUi = "androidx.compose.ui:ui:1.0.5"
const val composeMaterial = "androidx.compose.material:material:1.0.5"
const val composeUiUtil = "androidx.compose.ui:ui-util:1.0.5"
const val composeUiToolPreview = "androidx.compose.ui:ui-tooling-preview:1.0.5"
const val composeUiTool = "androidx.compose.ui:ui-tooling:1.0.5"
const val composeNavigation = "androidx.navigation:navigation-compose:2.4.0-alpha10"
const val composeLiveDate = "androidx.compose.runtime:runtime-livedata:1.0.5"
const val composeConstraint = "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
}
}
Для разделения ответственности модулей, работу с компонентами Compose вынесену в отдельный модуль под названием compose-component в котором будут содержаться UI элементы, цвет, шрифты и screen @Composable контейнеры для отрисовки окон интерфейса приложения собранного из разных UI компонентов кнопочек, полей ввода, текста.
Рассмотрим Color.kt который содержит в себе цвета использующиеся в приложения
val uiBorder = Color(0x1f000000)
val textHelp = Color(0xbdffffff)
val uiBackground = Color(0xFF2B2C2F)
val borderYear = Color(0x1AFFFFFF)
val uiBlue = Color(0xFF0064FF)
val uiError = Color(0xFFFF007A)
val lightBlue = Color(0xFF349EFF)
val blueBackgroundButton = Color(0xFF0064FF)
Shape.kt используется в Modifier для закругления границ UI элементов
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp)
)
Type.kt содержит в себе шрифты с сопутствующими настройками для отображения текста в интерфейсе
private val Montserrat = FontFamily(
Font(R.font.montserrat_regular, FontWeight.Normal),
Font(R.font.montserrat_bold, FontWeight.Bold)
)
placeholderTyp = TextStyle(
fontFamily = Montserrat,
fontSize = 16.sp,
color = Color.LightGray,
fontWeight = FontWeight.Light,
lineHeight = 16.sp,
letterSpacing = 1.25.sp
)
PromoCodeUI.kt это screen окна активации промо кода включающий в себя две @Composable функции для состояния экрана активации с вводом промо кода и успешной активации
@Composable
fun activatePromoCode(
valuePromoCode: (String) -> Unit,
onNext: () -> Unit,
screenState: PromoCodeState?
) {
var promoCodeInput by rememberSaveable { mutableStateOf("") }
val state = PromoCodeState(screenState?.state ?: ScreenState.UNKNOWN)
val isError = state.isFailed && promoCodeInput.isNotBlank()
val focusRequester = FocusRequester()
LaunchedEffect(true) {
focusRequester.requestFocus()
}
ConstraintLayout(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 30.dp)) {
val (containerContent, header) = createRefs()
createVerticalChain(containerContent, chainStyle = ChainStyle.Packed(0.3F))
Text(
modifier = Modifier
.fillMaxWidth()
.constrainAs(header) {
top.linkTo(parent.top, margin = 31.dp)
//растягиваем по всей ширине контейнера с дальнейшим обрезанием текста если осуществился выход за границы
width = Dimension.fillToConstraints
},
text = "Промокод".uppercase(),
style = headerCatalog,
textAlign = TextAlign.Center
)
Column(
modifier = Modifier
.fillMaxWidth()
.constrainAs(containerContent) {
top.linkTo(header.bottom)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start, margin = 20.dp)
end.linkTo(parent.end, margin = 20.dp)
}
) {
Text(
modifier = Modifier
.padding(bottom = 35.dp)
.align(Alignment.CenterHorizontally),
style = headerTyp,
color = Color.White,
textAlign = TextAlign.Center,
text = "Чтобы начать пользоваться приложением активируйте промо код"
)
GbTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = if (isError) 10.dp else 20.dp)
.align(Alignment.CenterHorizontally)
.focusRequester(focusRequester),
value = promoCodeInput,
onValueChange = {
promoCodeInput = it
valuePromoCode.invoke(it)
},
placeholder = "Введите промокод",
isError = state.isFailed
)
if (isError) {
Text(
modifier = Modifier
.padding(bottom = 20.dp)
.align(Alignment.Start),
style = textFieldErrorTyp,
fontSize = 14.sp,
textAlign = TextAlign.Start,
text = "Такого промокода не существует"
)
}
GbBlueButton(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.align(Alignment.CenterHorizontally),
textButton = "Активировать".uppercase(),
onClick = onNext
)
}
}
}
@Composable
fun successPromoCode(
onFinish: () -> Unit,
isCheckPermission: Boolean
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 35.dp, end = 35.dp, top = 123.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.ic_support_check_ok),
contentDescription = null,
colorFilter = ColorFilter.tint(Color.White),
alignment = Alignment.Center,
modifier = Modifier
.padding(bottom = 35.dp)
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 15.dp),
text = "Промокод активирован".uppercase(),
style = headerCatalog,
textAlign = TextAlign.Center
)
Text(
modifier = Modifier
.padding(bottom = 60.dp),
style = headerTyp,
color = Color.White,
textAlign = TextAlign.Center,
text = "Подписка успешно активирована"
)
val textButtonSuccess = if (isCheckPermission)
"Перейти к играм"
else
"Перейти к разрешениям"
GbBlueButton(
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
textButton = textButtonSuccess.uppercase(),
onClick = onFinish
)
}
}
@Preview()
@Composable
private fun PromoCodeContentPreview() {
activatePromoCode({}, {}, screenState = PromoCodeState(ScreenState.FAILED))
}
Button.kt файлик с кнопочками использующиеся в проекте
@Composable
internal fun GbStateCardButton(
state: StateDownload,
modifier: Modifier = Modifier,
contentPadding: PaddingValues,
onClick: () -> Unit
) {
val isInstallComplete = state == StateDownload.installComplete
Button(
onClick = { onClick.invoke() },
contentPadding = contentPadding,
modifier = modifier,
colors =
if (isInstallComplete)
ButtonDefaults.outlinedButtonColors(
backgroundColor = Color.Transparent,
contentColor = uiError
)
else
ButtonDefaults.buttonColors(
backgroundColor = uiBlue,
contentColor = Color.White
),
border = if (isInstallComplete) BorderStroke(1.dp, uiError) else null
) {
when (state) {
StateDownload.wait, StateDownload.error ->
Text(text = stringResource(id = R.string.game_card_download), style = textBoldMontserrat)
StateDownload.downloadComplete ->
Text(text = stringResource(id = R.string.game_card_install), style = textBoldMontserrat)
StateDownload.download ->
Text(text = stringResource(id = R.string.game_card_stop), style = textBoldMontserrat)
StateDownload.installComplete ->
Text(text = stringResource(id = R.string.game_card_delete), style = textBoldMontserrat, color = uiError)
}
}
}
StateDownload enum class опрделяющий состояние кнопки в карточке игры
enum class StateDownload {
//игры скачивается, показывать кнопку остановить
download,
//игры скачана, показывать кнопку установить
downloadComplete,
//игра установлена, показывать кнопку удалить
installComplete,
//ожидание скачивания, показывать кнопку скачать
wait,
//ошибка скачивания, показывать кнопку скачать
error
}
PromoCodeState data class хранящий в себе состояние валидации введенного промо кода.
data class PromoCodeState(var state: ScreenState = ScreenState.UNKNOWN) {
val isFailed: Boolean
get() = state == ScreenState.FAILED
val isSuccess: Boolean
get() = state == ScreenState.SUCCESS
}
В функции PromoCodeContentPreview() вывожу на preview экран ввода промо кода, то как он бы выглядел при запущенному эмуляторе или устройстве.
,
var promoCodeInput by rememberSaveable { mutableStateOf("") }
отличительной особенностью от remember является, что Saveable хранит в себе состояние введённого значения при перевороте экрана.
Начнём первую интеграцию, подружим fragment с @Composable функцией
PromoCodeFragment.kt
@AndroidEntryPoint
class PromoCodeFragment : BaseFragment() {
override val screenViewModel by viewModels<PromoCodeViewModel>()
override var isCompose: Boolean = true
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setContent {
PromoCodeScreen(
valuePromoCode = {
screenViewModel.savePromoCode(it)
},
onNext = { screenViewModel.checkPromoCode() },
onFinish = {
},
isCheckPermission = requireArguments().getBoolean(CHECK_PERMISSION, false)
)
}
}
}
@Composable
fun PromoCodeScreen(
valuePromoCode: (String) -> Unit,
onNext: () -> Unit,
onFinish: () -> Unit,
isCheckPermission: Boolean
) {
val screenState = screenViewModel.promoCodeState.asLiveData().observeAsState()
GbSurface {
when(screenState.value?.state ?: ScreenState.UNKNOWN) {
ScreenState.FAILED, ScreenState.UNKNOWN -> {
activatePromoCode (
onNext = onNext,
valuePromoCode = valuePromoCode,
screenState = screenState.value
)
}
ScreenState.SUCCESS -> {
hideSoftKeyboard()
successPromoCode(
onFinish = onFinish,
isCheckPermission = isCheckPermission
)
}
}
}
}
companion object {
private const val CHECK_PERMISSION = "check_permission"
fun newInstance(isCheck: Boolean) = PromoCodeFragment().apply {
arguments = bundleOf(CHECK_PERMISSION to isCheck)
}
}
}
для отображения ранее написанных screen в методе onCreateView фрагмента используем ComposeView(context: Context)
Теперь попробуем все списки RecyclerView использующиеся в проекте пере использовать, для этого возьмем источник данных UI это будет Adapter, если ранее для отображения элемента списка в методе onCreateViewHolder делали inflate layout то теперь здесь будем передавать compose view holder
class SearchAdapter(
private val showCard: (CatalogTemplateAdapterModel) -> Unit
) : ListAdapter<CatalogTemplateAdapterModel, ComposeCardItemViewHolder>(DiffUtilCallback()) {
private var arr: List<CatalogTemplateAdapterModel> = emptyList()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): ComposeCardItemViewHolder {
return ComposeCardItemViewHolder(
showCard = showCard,
composeView = ComposeView(parent.context)
)
}
override fun onViewRecycled(holderCardItem: ComposeCardItemViewHolder) {
holderCardItem.composeView.disposeComposition()
}
override fun onBindViewHolder(holderCardItem: ComposeCardItemViewHolder, position: Int) {
holderCardItem.bind(arr[position])
}
override fun getItemCount() = arr.size
fun setArr(catalogCard: List<CatalogTemplateAdapterModel>) {
this.arr = catalogCard
notifyDataSetChanged()
}
fun existCard() = arr.isNotEmpty()
}
class ComposeCardItemViewHolder(
val showCard: (CatalogTemplateAdapterModel) -> Unit,
val composeView: ComposeView,
private val width: Int = 0
) : RecyclerView.ViewHolder(composeView) {
init {
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
}
fun bind(input: CatalogTemplateAdapterModel) {
composeView.setContent {
if(width != 0){
CartItem(input, showCard, modifier = Modifier.width(width.dp))
} else {
CartItem(input, showCard)
}
}
}
}
во ViewHolder класса ComposeCardItemViewHolder в блоке init для корректной работы жизненного цикла item очищаем их при помощи установки стратегии ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed при удалении view очищаем item с данными.
Итоговый результат представлен на видео фрагменте
В методе bind класса ComposeCardItemViewHolder ранее было присваивание элементам интерфейса значений, теперь стало проще с compose передаём в @Composable функцию данные модельки и происходит отрисовка соответствующего контента.
Заключение: в итоге интеграция Compose в существующий проект занимает не так много сил, как это ожидается. Желаю всем попробовать и у себя в проекте применить методики compose, начинать нужно с малого.
Примечание: мой первый опыт публикаций статей на Habr.