Slide 1

Slide 1 text

Ozon Tech 2023 Декларативная дизайн-система: Figma + SwiftUI/Jetpack Compose Павел Максимишин, руководитель разработки дизайн-системы iOS Максим Поздеев, разработчик дизайн-системы Android

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

Что это и для чего? Дизайн-система 4 Дизайн-система — это продукт, который структурирует набор компонентов и гайдлайнов Для чего она нужна? • Консистентность • Переиспользуемость • Больше времени на UX • Единая конфигурация и глобальные настройки • Мягкий онбординг

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

Генерация токенов 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" }, …

Slide 12

Slide 12 text

Генерация токенов 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" }, …

Slide 13

Slide 13 text

Токены в коде 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 🍏

Slide 14

Slide 14 text

Что это? Атомы 14 • Компоненты на основе токенов • Есть размерная сетка • Могут использоваться отдельно

Slide 15

Slide 15 text

Что это? Организмы 15 • Многосоставной компонент • Удобный контракт как в Figma, так и в коде • Может быть основой для целого экрана

Slide 16

Slide 16 text

Врапперы 16 Что это? организм-контейнер, состоящий из двух компонентов, управляющий их размерами и позиционированием Враппер —

Slide 17

Slide 17 text

Ключевой компонент для ячеек MainAddonWrapper 17 • Основа для всех врапперов • Отвечает за лейаут • Настраивается с помощью пресетов Addon Main

Slide 18

Slide 18 text

Набор настроек Пресеты 18 Содержит: • Ось размещения • Общее и индивидуальное выравнивания • Минимальную высоту • Отступы между и вокруг содержимого • Индивидуальные отступы компонентов

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Как выглядит компонент дизайн-системы в коде 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, ) } }

Slide 26

Slide 26 text

Как выглядит компонент дизайн-системы в коде iOS Figma и верстка — очень просто 26 DSTitleSubtitle(title: "Заголовок", subtitle: "Подзаголовок") .dsIconAddon(iconToken: .M.personFilled, iconShape: .squircle) .dsDisclosureAddon() .dsIconAddonPreset(.shape600CenterStart500) .dsIconStyle(.init(iconColor: .graphicSecondary))

Slide 27

Slide 27 text

Как выглядит компонент дизайн-системы в коде 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))

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

Полезный инструмент 1. Библиотеки • Paparazzi (Android) • Swift-snapshot-testing (iOS) Snapshot-тестирование 34 2. Генерируем на основе Preview • KSP (Android) • Pre fi re (iOS) 3. Помогает отслеживать ошибки 4. Используем на ревью

Slide 35

Slide 35 text

Полезный инструмент 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, ) } } } }

Slide 36

Slide 36 text

Полезный инструмент 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) } } } } }

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

Стабильные типы и нестабильные типы 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 (неизменяемый)

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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 ) Вот он

Slide 45

Slide 45 text

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 ) Нестабильный

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

Это еще не все 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, )

Slide 51

Slide 51 text

Это еще не все 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, ) }

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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() }

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

MainAddonWrapper 57 Addon Main

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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 } …

Slide 60

Slide 60 text

MainAddonWrapper + Layout 60 Leaderboard Avatars Voting buttons

Slide 61

Slide 61 text

MainAddonWrapper + Layout 61 Backport?

Slide 62

Slide 62 text

Два основных метода Использование 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 }

Slide 63

Slide 63 text

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) ) }

Slide 64

Slide 64 text

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) ) }

Slide 65

Slide 65 text

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) ) }

Slide 66

Slide 66 text

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 }

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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) } }

Slide 73

Slide 73 text

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) … }

Slide 74

Slide 74 text

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) … }

Slide 75

Slide 75 text

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) }

Slide 76

Slide 76 text

Выводы 76

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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