$30 off During Our Annual Pro Sale. View Details »

Максим Поздеев и Павел Максимишин – Декларативная дизайн-система: Figma + SwiftUI/Jetpack Compose

Ozon Tech
August 30, 2023

Максим Поздеев и Павел Максимишин – Декларативная дизайн-система: Figma + SwiftUI/Jetpack Compose

Ozon Tech

August 30, 2023
Tweet

More Decks by Ozon Tech

Other Decks in Technology

Transcript

  1. Ozon Tech 2023
    Декларативная дизайн-система:


    Figma + SwiftUI/Jetpack Compose
    Павел Максимишин, руководитель разработки дизайн-системы iOS


    Максим Поздеев, разработчик дизайн-системы Android

    View Slide

  2. План
    2
    1. О дизайн-системе
    2. Как это использовать
    3. Демо-приложение
    4. Snapshot-тестирование
    5. Интересное в Android
    6. Интересное в iOS
    7. Выводы

    View Slide

  3. О дизайн-системе
    3

    View Slide

  4. Что это и для чего?
    Дизайн-система
    4
    Дизайн-система —
    это продукт, который
    структурирует набор
    компонентов и гайдлайнов
    Для чего она нужна?


    • Консистентность


    • Переиспользуемость


    • Больше времени на UX


    • Единая конфигурация и
    глобальные настройки


    • Мягкий онбординг

    View Slide

  5. Молекулярная система
    Структура дизайн-системы
    5
    Токены
    Атомы
    Организмы

    View Slide

  6. Что это?
    Токены
    6

    View Slide

  7. Что это?
    Токены
    7

    View Slide

  8. Генерация токенов
    8

    View Slide

  9. Генерация токенов
    9



    "accent-primary": {


    "value": "{core.rose.500}",


    "type": "color"


    },


    "accent-secondary": {


    "value": "{core.rose-transparent.300}",


    "type": "color"


    },



    View Slide

  10. Генерация токенов
    10



    "accent-primary": {


    "value": "{core.rose.500}",


    "type": "color"


    },


    "accent-secondary": {


    "value": "{core.rose-transparent.300}",


    "type": "color"


    },



    View Slide

  11. Генерация токенов
    11
    static let bgAccentPrimary = ColorToken(name: "bgAccentPrimary",


    type: .dynamic(Self.coreRose500.staticColorValue,


    Self.coreRose500.staticColorValue))


    static let bgAccentSecondary = ColorToken(name: "bgAccentSecondary",


    type: .dynamic(Self.coreRoseTransparent300.staticColorValue,


    Self.coreRoseTransparent500.staticColorValue))



    "accent-primary": {


    "value": "{core.rose.500}",


    "type": "color"


    },


    "accent-secondary": {


    "value": "{core.rose-transparent.300}",


    "type": "color"


    },



    View Slide

  12. Генерация токенов
    12
    bg = BgColors(
    accentPrimary = DsCoreColors.rose500,
    accentSecondary = DsCoreColors.roseTransparent500,
    …,
    ),



    "accent-primary": {


    "value": "{core.rose.500}",


    "type": "color"


    },


    "accent-secondary": {


    "value": "{core.rose-transparent.300}",


    "type": "color"


    },



    View Slide

  13. Токены в коде
    13
    IconToken.M.protectionSuccess.image


    .foregroundColor(ColorToken.graphicPositivePrimary.color)
    Icon(
    painter = DsTheme.icons.ic_m_protection_success,
    tint = DsTheme.colors.graphic.positivePrimary,
    contentDescription = null,
    )
    Android 🤖
    iOS 🍏

    View Slide

  14. Что это?
    Атомы
    14
    • Компоненты на основе токенов


    • Есть размерная сетка


    • Могут использоваться отдельно

    View Slide

  15. Что это?
    Организмы
    15
    • Многосоставной компонент


    • Удобный контракт как в Figma,
    так и в коде


    • Может быть основой для
    целого экрана

    View Slide

  16. Врапперы
    16
    Что это?
    организм-контейнер, состоящий


    из двух компонентов, управляющий
    их размерами и позиционированием
    Враппер —

    View Slide

  17. Ключевой компонент для ячеек
    MainAddonWrapper
    17
    • Основа для всех
    врапперов


    • Отвечает за лейаут


    • Настраивается с помощью
    пресетов Addon Main

    View Slide

  18. Набор настроек
    Пресеты
    18
    Содержит:
    • Ось размещения


    • Общее и индивидуальное
    выравнивания


    • Минимальную высоту


    • Отступы между и вокруг
    содержимого


    • Индивидуальные отступы
    компонентов

    View Slide

  19. И это только малая часть
    Что можно сделать из врапперов
    19

    View Slide

  20. Как это использовать
    20

    View Slide

  21. Как выглядит компонент дизайн-системы в Figma
    Figma и верстка — очень просто
    21

    View Slide

  22. Как выглядит компонент дизайн-системы в Figma
    Figma и верстка — очень просто
    22

    View Slide

  23. Как выглядит компонент дизайн-системы в Figma
    Figma и верстка — очень просто
    23

    View Slide

  24. Как выглядит компонент дизайн-системы в Figma
    Figma и верстка — очень просто
    24

    View Slide

  25. Как выглядит компонент дизайн-системы в коде Android
    Figma и верстка — очень просто
    25
    DsDisclosureAddonWrapper(


    imagePainter = DsTheme.icons.ic_m_chevron_right,


    preset = DisclosureAddonWrapperPreset.Image500CenterEnd,


    ) {


    DsIconAddonWrapper(


    iconGraphic = DsTheme.icons.ic_m_person_filled,


    preset = IconAddonWrapperPreset.Shape600CenterStart500,


    ) {


    DsTitleSubtitle(


    titleLabel = "Заголовок",


    subtitleLabel = "Подзаголовок",


    preset = TitleSubtitlePreset.ContentDefault500,


    )


    }


    }


    View Slide

  26. Как выглядит компонент дизайн-системы в коде iOS
    Figma и верстка — очень просто
    26
    DSTitleSubtitle(title: "Заголовок", subtitle: "Подзаголовок")


    .dsIconAddon(iconToken: .M.personFilled, iconShape: .squircle)


    .dsDisclosureAddon()


    .dsIconAddonPreset(.shape600CenterStart500)


    .dsIconStyle(.init(iconColor: .graphicSecondary))

    View Slide

  27. Как выглядит компонент дизайн-системы в коде iOS
    Figma и верстка — очень просто
    27
    DisclosureAddonWrapper {


    IconAddonWrapper(iconToken: .M.personFilled, iconShape: .squircle) {


    DSTitleSubtitle(title: "Заголовок", subtitle: "Подзаголовок")


    }


    }


    .dsIconAddonPreset(.shape600CenterStart500)


    .dsIconStyle(.init(iconColor: .graphicSecondary))
    Реализация «под капотом»
    DSTitleSubtitle(title: "Заголовок", subtitle: "Подзаголовок")


    .dsIconAddon(iconToken: .M.personFilled, iconShape: .squircle)


    .dsDisclosureAddon()


    .dsIconAddonPreset(.shape600CenterStart500)


    .dsIconStyle(.init(iconColor: .graphicSecondary))

    View Slide

  28. Демо-приложение
    28
    Где смотреть функциональность компонентов

    View Slide

  29. Для чего оно?
    Демо-приложение
    29
    1. Дизайн-ревью
    2. Принцип работы компонентов
    3. Вся дизайн-система
    в одном месте

    View Slide

  30. 30
    Демо-приложение

    View Slide

  31. Демо-приложение
    31

    View Slide

  32. Демо-приложение
    32

    View Slide

  33. Полезный инструмент
    Snapshot-тестирование
    33

    View Slide

  34. Полезный инструмент
    1. Библиотеки


    • Paparazzi (Android)


    • Swift-snapshot-testing (iOS)
    Snapshot-тестирование
    34
    2. Генерируем на основе Preview


    • KSP (Android)


    • Pre
    fi
    re (iOS)
    3. Помогает отслеживать ошибки
    4. Используем на ревью

    View Slide

  35. Полезный инструмент
    Snapshot-тестирование
    35
    @Composable
    internal fun BadgesPreview() = MultiThemedPreviewColumn(
    padding = 2.dp,
    ) {
    val styles = BadgeStyle.values()
    val sizes = BadgeSize.values()
    styles.forEach { (_, style) ->
    Row {
    sizes.forEach { size ->
    DsBadge(
    …,
    size = size,
    style = style,
    )
    }
    }
    }
    }

    View Slide

  36. Полезный инструмент
    Snapshot-тестирование
    36
    struct DSBadge_Previews: PreviewProvider, PrefireProvider {


    static var previews: some View {


    VStack {


    ForEach(DSBadgeStyle.allCases) { style in


    HStack(spacing: 16) {


    ForEach(DSBadgeSize.allCases) { size in


    DSBadge("Action", iconToken: .M.statusPointsFilled) {}


    .dsComponentsSize(size.common)


    }


    .dsBadgeStyle(style)


    }


    }


    }


    }


    }

    View Slide

  37. Генерация snapshot-тестов
    Максим Гришутин, руководитель iOS-разработки

    View Slide

  38. Следующий доклад
    Snapshot-ы в тестировании
    дизайн-системы
    Дарья Поснова, старший специалист по тестированию

    View Slide

  39. Интересное в Android 🤖
    39

    View Slide

  40. О чем пойдет речь?
    40
    1. База Compose
    2. Описание проблемы
    3. Вариант решения

    View Slide

  41. Композиция и рекомпозиция в Jetpack Compose
    41
    LoginScreen
    LoginInput
    LoginScreen
    LoginError
    LoginInput
    Recomposition


    (showError = true)
    • Композиция — процесс построения дерева composable функций
    • Рекомпозиция — процесс обновления дерева composable
    функций при изменении входных данных.

    View Slide

  42. Стабильные типы и нестабильные типы
    42
    @Stable
    class SearchState(query: String) {
    var query by mutableStateOf(query)
    }
    @Immutable
    public data class IconBorder(
    val isInside: Boolean,
    val color: Color = Color.Unspecified,
    val width: Dp = Dp.Unspecified,
    )
    Stable


    (сам уведомляет об изменениях)
    Immutable


    (неизменяемый)

    View Slide

  43. Пропускаемые функции
    43
    • Пропускаемая (skippable) функция — функция, которую
    Сompose не вызовет в процессе рекомпозиции, если ее
    данные не изменились
    • Непропускаемая (non-skippable) функция — функция, которую
    Сompose в любом случае вызовет в процессе рекомпозиции, вне
    зависимости от того изменились ли ее данные

    View Slide

  44. Painter
    44
    @Composable
    fun Image(
    painter: Painter,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    alignment: Alignment = Alignment.Center,
    contentScale: ContentScale = ContentScale.Fit,
    alpha: Float = DefaultAlpha,
    colorFilter: ColorFilter? = null
    )
    Вот он

    View Slide

  45. Painter нестабильный
    45
    @Composable
    fun Image(
    painter: Painter,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    alignment: Alignment = Alignment.Center,
    contentScale: ContentScale = ContentScale.Fit,
    alpha: Float = DefaultAlpha,
    colorFilter: ColorFilter? = null
    )
    Нестабильный

    View Slide

  46. ImmutablePainter
    46
    @Immutable
    public data class ImmutablePainter(
    val value: Painter,
    )
    Вариант решения проблемы
    Решение не финальное!

    View Slide

  47. Как помогает оптимизация Painter
    47
    @Composable
    public fun DsIconAddonWrapper(
    iconGraphic: Painter?,
    preset: IconAddonWrapperPreset,
    modifier: Modifier = Modifier,
    …,
    )
    @Composable
    public fun DsIconAddonWrapper(
    iconGraphic: ImmutablePainter?,
    preset: IconAddonWrapperPreset,
    modifier: Modifier = Modifier,
    …,
    )

    View Slide

  48. 48
    Количество рекомпозиций с Painter
    0
    0
    Оба DsIconAddonWrapper рекомпозируются вне
    зависимости от того, какой из них изменяется
    Количество
    рекомпозиций
    Количество
    пропусков

    View Slide

  49. 49
    Количество рекомпозиций с ImmutablePainter
    Оба DsIconAddonWrapper рекомпозируются только
    при их изменении. Лишних рекомпозиций нет
    Количество
    рекомпозиций
    Количество
    пропусков

    View Slide

  50. Это еще не все
    50
    Есть другие способы
    @Immutable
    internal data class ProductItemIcons(
    val kebabIcon: Painter,
    val selectedIcon: Painter,
    val unselectedIcon: Painter,
    val chipCheckIcon: Painter,
    val chipEyeIcon: Painter,
    val chipExclamationIcon: Painter,
    val chipCheckSeenIcon: Painter,
    )

    View Slide

  51. Это еще не все
    51
    Есть другие способы
    val kebabIconPainter = painterResource(kebabIcon)
    val selectedIconPainter = painterResource(selectedIcon)
    val unselectedIconPainter = painterResource(unselectedIcon)
    val chipCheckIconPainter = painterResource(chipCheckIcon)
    val chipEyeIconPainter = painterResource(chipEyeIcon)
    val chipExclamationIconPainter = painterResource(chipExclamationIcon)
    val chipCheckSeenIconPainter = painterResource(chipCheckSeenIcon)
    return remember {
    ProductItemIcons(
    kebabIcon = kebabIconPainter,
    selectedIcon = selectedIconPainter,
    unselectedIcon = unselectedIconPainter,
    chipCheckIcon = chipCheckIconPainter,
    chipEyeIcon = chipEyeIconPainter,
    chipExclamationIcon = chipExclamationIconPainter,
    chipCheckSeenIcon = chipCheckSeenIconPainter,
    )
    }

    View Slide

  52. ImmutablePainter — не финальное решение
    52
    ImageVector
    @Immutable
    class ImageVector internal constructor(

    )

    View Slide

  53. ImageVector
    53
    Поддерживается в стандартных функциях
    @Immutable
    class ImageVector internal constructor(

    )
    ImageVector

    View Slide

  54. Material icons и ImageVector
    54
    _accountCircle = materialIcon(name = "Filled.AccountCircle") {
    materialPath {
    moveTo(12.0f, 2.0f)
    curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f)
    reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f)
    reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f)
    reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f)
    close()
    moveTo(12.0f, 6.0f)
    curveToRelative(1.93f, 0.0f, 3.5f, 1.57f, 3.5f, 3.5f)
    reflectiveCurveTo(13.93f, 13.0f, 12.0f, 13.0f)
    reflectiveCurveToRelative(-3.5f, -1.57f, -3.5f, -3.5f)
    reflectiveCurveTo(10.07f, 6.0f, 12.0f, 6.0f)
    close()
    moveTo(12.0f, 20.0f)
    curveToRelative(-2.03f, 0.0f, -4.43f, -0.82f, -6.14f, -2.88f)
    curveTo(7.55f, 15.8f, 9.68f, 15.0f, 12.0f, 15.0f)
    reflectiveCurveToRelative(4.45f, 0.8f, 6.14f, 2.12f)
    curveTo(16.43f, 19.18f, 14.03f, 20.0f, 12.0f, 20.0f)
    close()
    }

    View Slide

  55. Что почитать об оптимизации Jetpack Compose
    Осознанная
    оптимизация Compose
    55

    View Slide

  56. Интересное в iOS 🍏
    56

    View Slide

  57. MainAddonWrapper
    57
    Addon Main

    View Slide

  58. MainAddonWrapper + VStack/HStack
    58
    View View
    View
    View
    HStack
    VStack

    View Slide

  59. MainAddonWrapper + VStack/HStack
    59



    .background(
    GeometryReader { geometry in
    Color.clear
    .preference(key: MaxWidthPreferenceKey.self, value: geometry.size.width)
    }
    )
    .onPreferenceChange(MaxWidthPreferenceKey.self) {
    // считаем vstack по ширине и строим по нему ширину внутренних вью
    self.minWidthContent = $0
    }



    View Slide

  60. MainAddonWrapper + Layout
    60
    Leaderboard
    Avatars
    Voting buttons

    View Slide

  61. MainAddonWrapper + Layout
    61
    Backport?

    View Slide

  62. Два основных метода
    Использование Layout
    62
    struct MainAddonVLayout: Layout {


    func sizeThatFits(


    proposal: ProposedViewSize,


    subviews: Subviews,


    cache: inout MainAddonCacheData


    ) -> CGSize {


    // Расчет размера контейнера


    }


    func placeSubviews(


    in bounds: CGRect,


    proposal: ProposedViewSize,


    subviews: Subviews,


    cache: inout MainAddonCacheData


    ) {


    // расположение View


    }

    View Slide

  63. Vertical Layout
    63
    func sizeThatFits(


    proposal: ProposedViewSize,


    subviews: Subviews,


    cache: inout MainAddonCacheData


    ) -> CGSize {


    guard !subviews.isEmpty else { return .zero }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    let containerSize = containerSize(for: sizes)


    return CGSize(


    width: distribution == .fit ? containerSize.width : proposalWidth,


    height: max(minHeight ?? 0, containerSize.height)


    )


    }

    View Slide

  64. Vertical Layout
    64
    /// предполагаемая ширина для лейаута в зависимости от распределения контента


    private func proposalWidth(proposal: ProposedViewSize, containerSize: CGSize) -> CGFloat {


    let fillSize = proposal.replacingUnspecifiedDimensions(by: containerSize)


    guard distribution == .fit else { return fillSize.width }


    return containerSize.width > fillSize.width ? fillSize.width : containerSize.width


    }
    func sizeThatFits(


    proposal: ProposedViewSize,


    subviews: Subviews,


    cache: inout MainAddonCacheData


    ) -> CGSize {


    guard !subviews.isEmpty else { return .zero }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    let containerSize = containerSize(for: sizes)


    return CGSize(


    width: distribution == .fit ? containerSize.width : proposalWidth,


    height: max(minHeight ?? 0, containerSize.height)


    )


    }

    View Slide

  65. Vertical Layout
    65
    /// расчет размера вьюшек относительно предполагаемой ширины контейнера


    private func calculateSizes(proposalWidth: CGFloat, subviews: Subviews) -> [CGSize] {


    subviews.map {


    $0.sizeThatFits(ProposedViewSize(width: proposalWidth, height: nil))


    }


    }
    func sizeThatFits(


    proposal: ProposedViewSize,


    subviews: Subviews,


    cache: inout MainAddonCacheData


    ) -> CGSize {


    guard !subviews.isEmpty else { return .zero }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    let containerSize = containerSize(for: sizes)


    return CGSize(


    width: distribution == .fit ? containerSize.width : proposalWidth,


    height: max(minHeight ?? 0, containerSize.height)


    )


    }

    View Slide

  66. Vertical Layout
    func sizeThatFits(


    proposal: ProposedViewSize,


    subviews: Subviews,


    cache: inout MainAddonCacheData


    ) -> CGSize {


    guard !subviews.isEmpty else { return .zero }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    let containerSize = containerSize(for: sizes)


    return CGSize(


    width: distribution == .fit ? containerSize.width : proposalWidth,


    height: max(minHeight ?? 0, containerSize.height)


    )


    }
    /// размер вертикального контейнера по размеру вью, включая gap


    private func containerSize(for subviewSizes: [CGSize]) -> CGSize {


    let gapSize: CGSize = subviewSizes.count == 2 ? CGSize(width: .zero, height: gap) : .zero


    let containerSize: CGSize = subviewSizes.reduce(gapSize) { containerSize, subviewSize in


    CGSize(


    width: max(containerSize.width, subviewSize.width),


    height: containerSize.height + subviewSize.height)


    }


    return containerSize


    }

    View Slide

  67. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout MainAddonCacheData) {


    guard !subviews.isEmpty else { return }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    var nextY = bounds.minY


    // при наличии минимальной высоты, проверяем общую высоту элементов,


    // при необходимости Y смещается на половину минимальной высоты


    if let minHeight {


    let totalHeight = sizes.reduce(gap) { $0 + $1.height }


    let offset = (minHeight - totalHeight) / 2


    if offset > 0 {


    nextY += offset


    }


    }


    let alignments = [mainAlignment, addonAlignment].map { $0 ?? alignment }


    // расположение вью относительно указанного addonSide


    let orderedIndices = subviews.indices.sorted { addonSide == .start ? $0 > $1 : $0 < $1 }


    for index in orderedIndices {


    nextY += sizes[index].height / 2


    placeSubview(


    subviews[index],


    in: bounds,


    offsetY: nextY,


    proposal: ProposedViewSize(sizes[index]),


    alignment: alignments[index]


    )


    nextY += sizes[index].height / 2 + gap


    }


    }
    Vertical Layout
    67

    View Slide

  68. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout MainAddonCacheData) {


    guard !subviews.isEmpty else { return }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    var nextY = bounds.minY


    // при наличии минимальной высоты, проверяем общую высоту элементов,


    // при необходимости Y смещается на половину минимальной высоты


    if let minHeight {


    let totalHeight = sizes.reduce(gap) { $0 + $1.height }


    let offset = (minHeight - totalHeight) / 2


    if offset > 0 {


    nextY += offset


    }


    }


    let alignments = [mainAlignment, addonAlignment].map { $0 ?? alignment }


    // расположение вью относительно указанного addonSide


    let orderedIndices = subviews.indices.sorted { addonSide == .start ? $0 > $1 : $0 < $1 }


    for index in orderedIndices {


    nextY += sizes[index].height / 2


    placeSubview(


    subviews[index],


    in: bounds,


    offsetY: nextY,


    proposal: ProposedViewSize(sizes[index]),


    alignment: alignments[index]


    )


    nextY += sizes[index].height / 2 + gap


    }


    }
    Vertical Layout
    68

    View Slide

  69. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout MainAddonCacheData) {


    guard !subviews.isEmpty else { return }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    var nextY = bounds.minY


    // при наличии минимальной высоты, проверяем общую высоту элементов,


    // при необходимости Y смещается на половину минимальной высоты


    if let minHeight {


    let totalHeight = sizes.reduce(gap) { $0 + $1.height }


    let offset = (minHeight - totalHeight) / 2


    if offset > 0 {


    nextY += offset


    }


    }


    let alignments = [mainAlignment, addonAlignment].map { $0 ?? alignment }


    // расположение вью относительно указанного addonSide


    let orderedIndices = subviews.indices.sorted { addonSide == .start ? $0 > $1 : $0 < $1 }


    for index in orderedIndices {


    nextY += sizes[index].height / 2


    placeSubview(


    subviews[index],


    in: bounds,


    offsetY: nextY,


    proposal: ProposedViewSize(sizes[index]),


    alignment: alignments[index]


    )


    nextY += sizes[index].height / 2 + gap


    }


    }
    Vertical Layout
    69

    View Slide

  70. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout MainAddonCacheData) {


    guard !subviews.isEmpty else { return }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    var nextY = bounds.minY


    // при наличии минимальной высоты, проверяем общую высоту элементов,


    // при необходимости Y смещается на половину минимальной высоты


    if let minHeight {


    let totalHeight = sizes.reduce(gap) { $0 + $1.height }


    let offset = (minHeight - totalHeight) / 2


    if offset > 0 {


    nextY += offset


    }


    }


    let alignments = [mainAlignment, addonAlignment].map { $0 ?? alignment }


    // расположение вью относительно указанного addonSide


    let orderedIndices = subviews.indices.sorted { addonSide == .start ? $0 > $1 : $0 < $1 }


    for index in orderedIndices {


    nextY += sizes[index].height / 2


    placeSubview(


    subviews[index],


    in: bounds,


    offsetY: nextY,


    proposal: ProposedViewSize(sizes[index]),


    alignment: alignments[index]


    )


    nextY += sizes[index].height / 2 + gap


    }


    }
    Vertical Layout
    70

    View Slide

  71. func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout MainAddonCacheData) {


    guard !subviews.isEmpty else { return }


    let proposalWidth = proposalWidth(proposal: proposal, containerSize: cache.containerSize)


    let sizes = calculateSizes(proposalWidth: proposalWidth, subviews: subviews)


    var nextY = bounds.minY


    // при наличии минимальной высоты, проверяем общую высоту элементов,


    // при необходимости Y смещается на половину минимальной высоты


    if let minHeight {


    let totalHeight = sizes.reduce(gap) { $0 + $1.height }


    let offset = (minHeight - totalHeight) / 2


    if offset > 0 {


    nextY += offset


    }


    }


    let alignments = [mainAlignment, addonAlignment].map { $0 ?? alignment }


    // расположение вью относительно указанного addonSide


    let orderedIndices = subviews.indices.sorted { addonSide == .start ? $0 > $1 : $0 < $1 }


    for index in orderedIndices {


    nextY += sizes[index].height / 2


    placeSubview(


    subviews[index],


    in: bounds,


    offsetY: nextY,


    proposal: ProposedViewSize(sizes[index]),


    alignment: alignments[index]


    )


    nextY += sizes[index].height / 2 + gap


    }


    }
    Vertical Layout
    71

    View Slide

  72. Vertical Layout
    72
    /// Располагает вью относительно выравнивания


    private func placeSubview(


    _ subview: Subviews.Element,


    in bounds: CGRect,


    offsetY: CGFloat,


    proposal: ProposedViewSize,


    alignment: DSAlignment


    ) {


    switch alignment {


    case .leading, .firstBaseline:


    subview.place(


    at: CGPoint(x: bounds.minX, y: offsetY),


    anchor: .leading,


    proposal: proposal)


    case .trailing, .lastBaseline:


    subview.place(


    at: CGPoint(x: bounds.maxX, y: offsetY),


    anchor: .trailing,


    proposal: proposal)


    case .center:


    subview.place(


    at: CGPoint(x: bounds.midX, y: offsetY),


    anchor: .center,


    proposal: proposal)


    }


    }

    View Slide

  73. Vertical Layout
    73
    /// Располагает вью относительно выравнивания


    private func placeSubview(


    _ subview: Subviews.Element,


    in bounds: CGRect,


    offsetY: CGFloat,


    proposal: ProposedViewSize,


    alignment: DSAlignment


    ) {





    subview.place(


    at: CGPoint(x: bounds.minX, y: offsetY),


    anchor: .leading,


    proposal: proposal)





    }

    View Slide

  74. Vertical Layout
    74
    /// Располагает вью относительно выравнивания


    private func placeSubview(


    _ subview: Subviews.Element,


    in bounds: CGRect,


    offsetY: CGFloat,


    proposal: ProposedViewSize,


    alignment: DSAlignment


    ) {





    subview.place(


    at: CGPoint(x: bounds.minX, y: offsetY),


    anchor: .leading,


    proposal: proposal)





    }

    View Slide

  75. Vertical Layout
    75
    func spacing(subviews: Subviews, cache: inout MainAddonCacheData) -> ViewSpacing {


    // дефолтные спейсинги внутри стэков вне зависимости от контента


    ViewSpacing()


    }
    func makeCache(subviews: Subviews) -> MainAddonCacheData {


    // вычисляется предположительный размер контейнера


    // по идеальному размеру для сабвью


    let containerSize = containerSize(for: subviews.map { $0.sizeThatFits(.unspecified) })


    return MainAddonCacheData(containerSize: containerSize)


    }

    View Slide

  76. Выводы
    76

    View Slide

  77. Выводы
    77
    1. SwiftUI и Jetpack Compose можно использовать
    для создания дизайн-системы
    2. SwiftUI и Jetpack Compose + Figma = декларативность
    3. Поддержка

    View Slide

  78. Советы
    78
    1. Обложите все Snapshot-тестами!
    2. Думайте про API
    3. Документация

    View Slide

  79. Спасибо за внимание
    Максимишин Павел, руководитель разработки дизайн системы iOS


    Поздеев Максим, разработчик дизайн системы Android

    View Slide