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

Комментарии (0)