Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Максим Поздеев и Павел Максимишин – Декларативн...

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
  2. План 2 1. О дизайн-системе 2. Как это использовать 3.

    Демо-приложение 4. Snapshot-тестирование 5. Интересное в Android 6. Интересное в iOS 7. Выводы
  3. Что это и для чего? Дизайн-система 4 Дизайн-система — это

    продукт, который структурирует набор компонентов и гайдлайнов Для чего она нужна? • Консистентность • Переиспользуемость • Больше времени на UX • Единая конфигурация и глобальные настройки • Мягкий онбординг
  4. Генерация токенов 9 … "accent-primary": { "value": "{core.rose.500}", "type": "color"

    }, "accent-secondary": { "value": "{core.rose-transparent.300}", "type": "color" }, …
  5. Генерация токенов 10 … "accent-primary": { "value": "{core.rose.500}", "type": "color"

    }, "accent-secondary": { "value": "{core.rose-transparent.300}", "type": "color" }, …
  6. Генерация токенов 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" }, …
  7. Генерация токенов 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" }, …
  8. Что это? Атомы 14 • Компоненты на основе токенов •

    Есть размерная сетка • Могут использоваться отдельно
  9. Что это? Организмы 15 • Многосоставной компонент • Удобный контракт

    как в Figma, так и в коде • Может быть основой для целого экрана
  10. Ключевой компонент для ячеек MainAddonWrapper 17 • Основа для всех

    врапперов • Отвечает за лейаут • Настраивается с помощью пресетов Addon Main
  11. Набор настроек Пресеты 18 Содержит: • Ось размещения • Общее

    и индивидуальное выравнивания • Минимальную высоту • Отступы между и вокруг содержимого • Индивидуальные отступы компонентов
  12. Как выглядит компонент дизайн-системы в коде 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, ) } }
  13. Как выглядит компонент дизайн-системы в коде iOS Figma и верстка

    — очень просто 26 DSTitleSubtitle(title: "Заголовок", subtitle: "Подзаголовок") .dsIconAddon(iconToken: .M.personFilled, iconShape: .squircle) .dsDisclosureAddon() .dsIconAddonPreset(.shape600CenterStart500) .dsIconStyle(.init(iconColor: .graphicSecondary))
  14. Как выглядит компонент дизайн-системы в коде 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))
  15. Для чего оно? Демо-приложение 29 1. Дизайн-ревью 2. Принцип работы

    компонентов 3. Вся дизайн-система в одном месте
  16. Полезный инструмент 1. Библиотеки • Paparazzi (Android) • Swift-snapshot-testing (iOS)

    Snapshot-тестирование 34 2. Генерируем на основе Preview • KSP (Android) • Pre fi re (iOS) 3. Помогает отслеживать ошибки 4. Используем на ревью
  17. Полезный инструмент 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, ) } } } }
  18. Полезный инструмент 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) } } } } }
  19. О чем пойдет речь? 40 1. База Compose 2. Описание

    проблемы 3. Вариант решения
  20. Композиция и рекомпозиция в Jetpack Compose 41 LoginScreen LoginInput LoginScreen

    LoginError LoginInput Recomposition (showError = true) • Композиция — процесс построения дерева composable функций • Рекомпозиция — процесс обновления дерева composable функций при изменении входных данных.
  21. Стабильные типы и нестабильные типы 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 (неизменяемый)
  22. Пропускаемые функции 43 • Пропускаемая (skippable) функция — функция, которую

    Сompose не вызовет в процессе рекомпозиции, если ее данные не изменились • Непропускаемая (non-skippable) функция — функция, которую Сompose в любом случае вызовет в процессе рекомпозиции, вне зависимости от того изменились ли ее данные
  23. 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 ) Вот он
  24. 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 ) Нестабильный
  25. ImmutablePainter 46 @Immutable public data class ImmutablePainter( val value: Painter,

    ) Вариант решения проблемы Решение не финальное!
  26. Как помогает оптимизация Painter 47 @Composable public fun DsIconAddonWrapper( iconGraphic:

    Painter?, preset: IconAddonWrapperPreset, modifier: Modifier = Modifier, …, ) @Composable public fun DsIconAddonWrapper( iconGraphic: ImmutablePainter?, preset: IconAddonWrapperPreset, modifier: Modifier = Modifier, …, )
  27. 48 Количество рекомпозиций с Painter 0 0 Оба DsIconAddonWrapper рекомпозируются

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

    их изменении. Лишних рекомпозиций нет Количество рекомпозиций Количество пропусков
  29. Это еще не все 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, )
  30. Это еще не все 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, ) }
  31. 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() }
  32. 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 } …
  33. Два основных метода Использование 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 }
  34. 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) ) }
  35. 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) ) }
  36. 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) ) }
  37. 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 }
  38. 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
  39. 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
  40. 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
  41. 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
  42. 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
  43. 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) } }
  44. 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) … }
  45. 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) … }
  46. 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) }
  47. Выводы 77 1. SwiftUI и Jetpack Compose можно использовать для

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