Начало

Все начинается в setContent. ComposeGenAppTheme необязательна. Surface кстати внутри себя содержит простой Box. Не привычно формировать все элементы без XML. Хотя интеграция во Fragmets как View возможна Using Compose in Views.

        setContent {
            ComposeGenAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {

Про TabRow расказывать не буду. Кому надо быстро скопипастит из проекта NoArchitecture‑Kotlin‑Compose.git а кто захочет вникнуть, на Youtybe есть видео. На английском весьма понятно. Ссылка внизу статьи.

                        TabRow(selectedTabIndex = selectedTabIndex) {
                            tabItems.forEachIndexed { index, item ->
                                Tab(selected = index == selectedTabIndex,
                                    onClick = {
                                        selectedTabIndex = index
                                    },
                                    text = {
                                        Text(item.title)
                                    },
                                    icon = {
                                        Icon(
                                            imageVector = if (index == selectedTabIndex) {
                                                item.selectedItem
                                            } else {
                                                item.unselectedItem
                                            },
                                            contentDescription = item.title
                                        )
                                    })
                            }
                        }

Предпросмотр

Для preview делается метод с аннотацией @Preview. Просмотреть что примерно выйдет можно с помощью метода помеченного этой аннотацией. Придется подготовить набор данных вручную.

Preview
Preview
@Preview
@Composable
fun PreviewConversation() {
    ComposeGenAppTheme {
        FalconInfoListView(SampleData.getRockets())
    }
}

Чтобы посмотреть как будет выводится список, генерируем данные в список из FalconInfo.

class SampleData {
    companion object {
        private const val mesageSize = 10;
        private val conversationSample: List<FalconInfo> = buildList<FalconInfo>(mesageSize) {
            for (i in 0 until mesageSize) {
                add(FalconInfo(name = "Name $i",
                    rocket = "Rocket $i",
                    details = "Detail can be long, Detail can be long, Detail can be long, Detail can be long, Detail can be long, Detail can be long, "))
            }
        }

        fun getRockets(): List<FalconInfo> {
            return conversationSample
        }
    }
}

Наблюдатели

Поначалу меня удивили remember. Оказалось удобная штука. Используется для конструирования наблюдателей в LaunchedEffect. Аналог Observer. И наверное он и есть. Например при изменении selectedTabIndex вызовется анимация. Благодаря этому подходу можно сделать полный фарш в вашем коде, главное самому потом разобраться.

                    var selectedTabIndex by remember {
                        mutableIntStateOf(0)
                    }


                    LaunchedEffect(selectedTabIndex) {
                        pagerState.animateScrollToPage(selectedTabIndex)
                    }

Получение данных из сети. Вывод списка

Для получения результата FalconInfo делается http запрос. Это код из примера. Чтобы как то распределить ответственность ввел mutableStateOf на прогресс и результат. Что не особо помогло. Потому что все стало бесконтрольно триггерится и появились ошибки.

                    var status by remember { mutableStateOf("Loading") }
                    ...
                    LaunchedEffect(true) {
                        scope.launch {
                            status = try {
                                rockets = Greeting().greeting()
                                "Ok"
                            } catch (e: Exception) {
                                e.localizedMessage ?: "error"
                            }
                        }
                    }
                    GreetingView(text)

                    

Http запрос и json парсер Ktor. А как же Retrofit?



class SpaceX {

    private val httpClient = HttpClient {
        install(HttpCache)
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
    }

    @Throws(Exception::class)
    suspend fun getRockets(): List<FalconInfo> {
        val rockets: Array<FalconInfo> =
            httpClient.get("https://api.spacexdata.com/v4/launches").body()
        return rockets.asList()
    }
}

Формирование списка элементов. Preview с моками использует его же. Магия отображения списков происходит в LazyColumn и надо его копнуть на предмет оптимизации. При первом старте есть притормаживания на старом флагмане. После первого прокручивания все становится плавным. Надо отдать должное авторам. С минимальным количеством кода, без холдеров и пэйлоадеров получается быстрый список. Надо посмотреть как его "прогреть" при старте если это возможно.

@Composable
fun FalconInfoListView(falconInfos: List<FalconInfo>) {
    LazyColumn {
        items(falconInfos) { falconInfos ->
            FalconInfoCard(falconInfos)
        }
    }
}

Дальше карточки. Ниже основной код где отображается список. PreviewMessageCard это предпросмотр одной строки списка. Из примера от Гугла взял реализацию выпадающего текста. Если текст длиннее одной строки. Кстати состояние не сохраняется, если проскролить вверх или вниз, когда элемент становится невидимым. recyclerview под капотом?

AsyncImage - из библиотекиcoil

Карточки
@Composable
fun FalconInfoCard(falconInfo: FalconInfo) {
    Row(modifier = Modifier.padding(all = 8.dp)) {

        AsyncImage(
            placeholder = rememberVectorPainter(Icons.Filled.Rocket),
            model = falconInfo.links?.patch?.small,
            contentDescription = null,
            contentScale = ContentScale.FillWidth,
            modifier = Modifier
                .size(40.dp)
                .clip(CircleShape)
            //.aspectRatio(0.8F)
        )

        Spacer(modifier = Modifier.width(8.dp))

        var isExpanded by remember { mutableStateOf(false) }

        val surfaceColor by animateColorAsState(
            if (isExpanded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
            label = "surf_color",
        )

        Column(modifier = Modifier.clickable { isExpanded = !isExpanded })
        {
            falconInfo.name?.let {
                Text(
                    text = it,
                    color = MaterialTheme.colorScheme.secondary
                )
            }

            Spacer(modifier = Modifier.height(4.dp))

            Surface(
                shape = MaterialTheme.shapes.medium,
                shadowElevation = 1.dp,
                // surfaceColor color will be changing gradually from primary to surface
                color = surfaceColor,
                // animateContentSize will change the Surface size gradually
                modifier = Modifier
                    .animateContentSize()
                    .padding(1.dp)
            ) {
                falconInfo.details?.let {
                    Text(
                        text = it,
                        maxLines = if (isExpanded) Int.MAX_VALUE else 1,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
}

@Preview
@Composable
fun PreviewMessageCard() {
    FalconInfoCard(
        FalconInfo(
            staticFireDateUtc = "234234234",
            staticFireDateUnix = 1,
            name = "Rocket", rocket = "N1",
            details = "Detail can be long, Detail can be long, Detail can be long, "
        )
    )

}

Карточка
Карточка

Проблемы

Появились проблемы с отображением переменных в Runtime. Пришлось покурить мануалы

The coroutine scope left the composition

https://developer.android.com/jetpack/compose/side-effects

produceState
launches a coroutine scoped to the Composition that can push values into a
returned State. Use it to
convert non-Compose state into Compose state, for example bringing external
subscription-driven state such as Flow, LiveData, or RxJava into the
Composition

Следующая версия с шаблонной оберткой. Теперь уже все приходило предсказуемо. НО при переходе между табами, проскакивает загрузка. в версии с LaunchedEffect из примера она не происходила. initialValue = ResponseResult.Loading - стоит ли кэшировать это состояние в переменной?

    val resLoad =
        produceState<ResponseResult<List<FalconInfo>>>(initialValue = ResponseResult.Loading) {
            value = try {
                ResponseResult.Success(SpaceX().getRockets())
            } catch (e: Exception) {
                ResponseResult.Error(e.localizedMessage ?: "error")
            }
        }

Кэширование можно перенести на сторону репозитария. produceState позволяет его передать. install(HttpCache) в пакете io.ktor.client.plugins.cache так же имеется.

return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) { 

Заключение

Хотелось бы сделать действительно кросс-платформенное решение. Compose это не позволяет делать в части UI. Может быть пока не позволяет?

Запустить xcode-kotlin не удалось. После установки плагина XCode перестало запускаться. Удалось запустить от рута sudo ./Xcode хотя kdoctor не нашел проблем.

brew install xcode-kotlin

xcode-kotlin install

MacBook MacOS % kdoctor                                                                                                    
Environment diagnose (to see all details, use -v option):
[✓] Operation System
[✓] Java
[✓] Android Studio
[✓] Xcode
[✓] Cocoapods

Conclusion:
  ✓ Your system is ready for Kotlin Multiplatform Mobile Development!

Мнение опытных Композиторов строго приветствуется.

Ссылки

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


  1. ReyzoR
    30.09.2023 18:14

    На счет статьи - не совсем понял для чего она ? Показать как можно на компоузе писать без архитектуры ? Если хочется что то проверить на коленке - это да. Если что то реализовывать, чисто для меня - даже маломальская логика в перемешку с ui дает ощущение грязи в коде. При чем тот же MVVM имплементится даже без сторонних либ и прост для освоения.

    На счет компоуза под ios - пробовали устанавливать по гайду ? https://github.com/touchlab/xcode-kotlin/blob/main/MANUAL_INSTALL.md


    1. app-z Автор
      30.09.2023 18:14

      https://github.com/touchlab/xcode-kotlin/issues/95

      Xcode crashes on launch with xcode-kotlin installed after updating to macOS 13.5

      похоже на этот баг

      Спасибо! Замечание дельное. сделал ветку на GitHub с навигацией. Что на счет Ktorfit как замену Retrofit? Стоит перейти? Не пробовали?


      1. ReyzoR
        30.09.2023 18:14

        Не пробовал. Обычно у меня это "голый" ktor. Вот использую его по разному. Если есть openapi/swagger - генерю по ним через openapi generator, он уже научился использовать ktor. Если нету - обычно ручками пишу, но в таких случаях обычно методов не много.
        Ktorfit - выглядит перспективно со своими 1.1к звездочек, но учитывая что за ним стоит один человек - боязно как то в продакшен тащить, потому что классический сценарий с автобусом никто не отменял :) (здоровья и куча хороших пуллреквестов автору)