Урок является продолжением MOKO Widgets #6 - platform Screen. Для выполнения данного урока нужно иметь проект, полученный в результате выполнения предыдущего урока.

Результатом прошлого урока было приложение с навигацией, стилизацией экранов, различными действиями на экранах, кастомными фабриками и платформенным экраном.

На этом уроке мы реализуем экран с списком элементов - это будет экран "друзья", на который можно попасть из профиля.

Добавим класс FriendsScreen основанный на WidgetsScreen: mpp-library/src/commonMain/kotlin/org/example/mpp/friends/FriendsScreen.kt:

class FriendsScreen(
    private val theme: Theme
) : WidgetScreen<Args.Empty>(), NavigationItem {

    override val navigationBar: NavigationBar = NavigationBar.Normal(title = "Friends".desc())

    override fun createContentWidget(): Widget<WidgetSize.Const<SizeSpec.AsParent, SizeSpec.AsParent>> {
        TODO()
    }
}

Контентом экрана будет ListWidget:

class FriendsScreen(
    ...
) : ... {
    ...

    override fun createContentWidget(): Widget<WidgetSize.Const<SizeSpec.AsParent, SizeSpec.AsParent>> {
        return with(theme) {
            list(
                size = WidgetSize.AsParent,
                id = Ids.List,
                items = TODO()
            )
        }
    }

    object Ids {
        object List : ListWidget.Id
    }
}

Positive : Для создания виджета списка нам требуется передать Id - это нужно чтобы Android мог сохранить положение скролла и восстановить при перезаходе на экран или смене конфигурации.

Для хранения данных нам потребуется сущность Friend: mpp-library/src/commonMain/kotlin/org/example/mpp/friends/Friend.kt:

data class Friend(
    val id: Int,
    val name: String,
    val avatarUrl: String
)

Источником данных сделаем FriendsViewModel: mpp-library/src/commonMain/kotlin/org/example/mpp/friends/FriendsViewModel.kt:

class FriendsViewModel : ViewModel() {
    private val _friends: MutableLiveData<List<Friend>> =
        MutableLiveData(
            initialValue = List(10) {
                Friend(
                    id = it,
                    name = "friend $it",
                    avatarUrl = "https://exchange.icinga.com/jschanz/Batman%20Theme%20%28Light%29/logo"
                )
            }
        )
    val friends: LiveData<List<Friend>> = _friends
}

Сделаем получение данных на экране: mpp-library/src/commonMain/kotlin/org/example/mpp/friends/FriendsScreen.kt:

class FriendsScreen(
    ...
) : ... {
    ...

    override fun createContentWidget(): Widget<WidgetSize.Const<SizeSpec.AsParent, SizeSpec.AsParent>> {
        val viewModel = getViewModel { FriendsViewModel() }

        return with(theme) {
            list(
                size = WidgetSize.AsParent,
                id = Ids.List,
                items = viewModel.friends.map { friendsToTableUnits(it) }
            )
        }
    }

    private fun Theme.friendsToTableUnits(friends: List<Friend>): List<TableUnitItem> {
        return friends.map { friend ->
            TODO()
        }
    }

    ...
}

Остается настроить преобразование элемента данных Friend в элемент списка - TableUnitItem.

mpp-library/src/commonMain/kotlin/org/example/mpp/friends/FriendUnitItem.kt:

class FriendUnitItem(
    private val theme: Theme,
    itemId: Long,
    friend: Friend
) : WidgetsTableUnitItem<Friend>(
    itemId = itemId,
    data = friend
) {
    override val reuseId: String = "friendCell"

    override fun createWidget(data: LiveData<Friend>): UnitItemRoot {
        TODO()
    }
}

Мы унаследовались от WidgetsTableUnitItem, это специальный TableUnitItem, который умеет создавать элемент списка с контентом полученным из Widget. Для корректной работы на iOS требуется задать уникальный reuseId для данного класса элемента.

Positive : В метод createWidget передается LiveData, эта лайвдата будет автоматически изменяться при переиспользовании уже созданной view.

Реализуем создание элемента:

class FriendUnitItem(
    ...
) : ... {
    ...

    override fun createWidget(data: LiveData<Friend>): UnitItemRoot {
        return with(theme) {
            constraint(
                size = WidgetSize.WidthAsParentHeightWrapContent
            ) {
                val title = +text(
                    size = WidgetSize.Const(
                        width = SizeSpec.MatchConstraint,
                        height = SizeSpec.WrapContent
                    ),
                    text = TODO(),
                )

                val avatar = +image(
                    size = WidgetSize.Const(
                        width = SizeSpec.Exact(64f),
                        height = SizeSpec.Exact(64f)
                    ),
                    image = TODO(),
                    scaleType = ImageWidget.ScaleType.FIT
                )

                constraints {
                    avatar.top pin root.top offset 16
                    avatar.left pin root.left offset 16
                    avatar.bottom pin root.bottom offset 16

                    title.left pin avatar.right offset 8
                    title.right pin root.right offset 16
                    title centerYToCenterY root
                }
            }
        }.let { UnitItemRoot.from(it) }
    }
}

Positive : UnitItemRoot - специальный класс, ограничивающий допустимые для использования в элементе списка размеры.

inline class UnitItemRoot private constructor(private val wrapper: Wrapper) {

    companion object {
        fun from(widget: Widget<WidgetSize.Const<SizeSpec.AsParent, SizeSpec.Exact>>): UnitItemRoot {
            return UnitItemRoot(Wrapper(widget))
        }

        fun from(widget: Widget<WidgetSize.Const<SizeSpec.AsParent, SizeSpec.WrapContent>>): UnitItemRoot {
            return UnitItemRoot(Wrapper(widget))
        }

        fun from(widget: Widget<WidgetSize.AspectByWidth<SizeSpec.AsParent>>): UnitItemRoot {
            return UnitItemRoot(Wrapper(widget))
        }
    }

    val widget: Widget<out WidgetSize> get() = wrapper.widget
}

Исходный код класса показывает, что доступно всего 3 варианта размеров:

За счет использования inline мы не накладываем дополнительную нагрузку на память - при компиляции класс будет стерт и использоваться будет widget напрямую.

Привязка данных

Мы создали элемент списка с иконкой и текстом, но данные пока не привязаны. Как было сказано выше - данные должны считываться из специальной LiveData, которая передается в метод createWidget. Создание виджета будет производиться только при создании новых View для списка. В остальных случаях будет переиспользоваться уже существующая View и привязка данных будет происходить через обновление LiveData.

class FriendUnitItem(
    ...
) : ... {
    ...

    override fun createWidget(data: LiveData<Friend>): UnitItemRoot {
        return with(theme) {
            constraint(
                size = WidgetSize.WidthAsParentHeightWrapContent
            ) {
                val title = +text(
                    ...
                    text = data.map { it.name.desc() as StringDesc }
                )

                val avatar = +image(
                    ...
                    image = data.map { Image.network(it.avatarUrl) },
                    ...
                )

                ...
            }
        }.let { UnitItemRoot.Companion.from(it) }
    }
}

mpp-library/src/commonMain/kotlin/org/example/mpp/friends/FriendsScreen.kt:

class FriendsScreen(
    ...
) : ... {
    ...

    private fun Theme.friendsToTableUnits(friends: List<Friend>): List<TableUnitItem> {
        return friends.map { friend ->
            FriendUnitItem(
                theme = theme,
                itemId = friend.id.toLong(),
                friend = friend
            )
        }
    }

    ...
}

Преобразуем список друзей в список юнитов, для отображения на UI.

Остается встроить экран в навигацию приложения.

Добавляем NavigationItem для экрана:

class FriendsScreen(
    ...
) : WidgetScreen<Args.Empty>(), NavigationItem {

    override val navigationBar: NavigationBar = NavigationBar.Normal(title = "Friends".desc())

    ...
}

Добавляем кнопку на экране профиля:

class ProfileScreen(
    ...
    private val routeFriends: Route<Unit>
) : ... {

    ...

    override fun createContentWidget() = with(theme) {
        constraint(size = WidgetSize.AsParent) {
            ...

            val friendsButton = +button(
                size = WidgetSize.WidthAsParentHeightWrapContent,
                content = ButtonWidget.Content.Text(Value.data("Friends".desc()))
            ) {
                routeFriends.route()
            }

            constraints {
                ...

                friendsButton topToBottom logoutButton
                friendsButton centerXToCenterX root
            }
        }
    }
}

Добавляем экран в фабрике профиля:

class ProfileFactory(
    ...
) {
    fun createProfileScreen(
        ...
        routeFriends: Route<Unit>
    ): ProfileScreen {
        return ProfileScreen(
            ...
            routeFriends = routeFriends
        )
    }

    ...

    fun createFriendsScreen(): FriendsScreen {
        return FriendsScreen(theme = theme)
    }
}

Добавляем его в приложении:

class App : BaseApplication() {
    ...

    private fun registerProfileTab(
        profileFactory: ProfileFactory,
        rootNavigationRouter: NavigationScreen.Router
    ): TypedScreenDesc<Args.Empty, ProfileNavigationScreen> {
        return registerScreen(ProfileNavigationScreen::class) {
            ...

            val friendsScreen = registerScreen(FriendsScreen::class) {
                profileFactory.createFriendsScreen()
            }

            val profileScreen = registerScreen(ProfileScreen::class) {
                profileFactory.createProfileScreen(
                    ...
                    routeFriends = navigationRouter.createPushRoute(friendsScreen)
                )
            }

            ...
        }
    }
}