Интеграция Compose в существующий проект Android

от автора

Всем привет, я 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.

Github: Joker4567


ссылка на оригинал статьи https://habr.com/ru/articles/593601/