Введение
У каждого iOS-разработчика рано или поздно появляется мысль: «А как же SwiftUI? Надо бы уже переходить на него — за ним будущее». Мы в Додо давно приняли эту мысль и постепенно встраиваем SwiftUI в свою дизайн-систему.
Как известно, SwiftUI — отличный фреймворк, чтобы набросать скелет компонента, а потом три дня его дебажить. Так вот: чтобы вам не пришлось проходить этот тернистый путь отладки, это сделали мы.
Всем привет! Меня зовут Михаил Андреев, я iOS-разработчик в Додо Пицце. Сегодня я научу вас смешивать цвета)
Проблема
Прямо сейчас у нас проходит A/B-тестирование обновлённой карточки продукта, в которой находится Segmented Control (SC). Для тех, кто не видел, вот картинка:
Обновлённая карточка продукта
Всё вроде бы круто, но… когда мы перемещаем слайдер, текст под ним никак не адаптируется.
Дело в том, что это старый компонент, написанный на UIKit
. Мы решили не исправлять его, а написать полностью новый на SwiftUI
, поскольку постепенно переводим нашу дизайн-систему на этот фреймворк.
Анализируем требования к SC:
🍕слайдер должен свободно перемещаться по SC;
🍕слайдер должен переместиться на ближайший из сегментов, рядом с которым его отпустил пользователь;
🍕SC должен быть разработан полностью на SwiftUI
без хаков через UIKit
;
🍕 при движении слайдера цвета сегментов должны меняться таким образом, чтобы обеспечивать прозрачную коммуникацию с элементом. Белые тексты должны становиться чёрными, а чёрные – белыми.
Что должно получиться:
Спойлер
Сразу оговорюсь, что системный Picker
из SwiftUI
нам не подошёл в силу недостаточной его кастомизируемости, так что будем писать полностью своё решение.
Теперь, когда мы точно понимаем, что нужно сделать, without further interruption let’s celebrate and write some code!
Рассуждаем и стелем соломку
На первый взгляд, структура нашего SC должна состоять из трёх слоёв, наложенных друг на друга:
-
слой backgroundColor;
-
слой слайдера;
-
слой контента SC.
Для начала определимся, что мы хотим видеть в нашем сегменте. Для простоты картины он будет содержать только текст:
public struct Segment: Hashable, Identifiable { let title: String }
Далее набросаем код нашего сегмента:
struct SegmentView: View { let title: String let isSelected: Bool let foregroundColor: Color let animation: Animation var body: some View { content .animation(animation, value: isSelected) } var content: some View { Text(title) // Здесь и далее в некоторых местах я буду оставлять такие константы, // если их значение понятно из контекста, чтобы не усложнять код .lineLimit(1) .foregroundStyle(foregroundColor) .padding(.vertical, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .fixedSize(horizontal: false, vertical: false) } }
Теперь мы готовы написать первую версию нашего SC!
Реализация SC
Далее по коду я буду использовать константы из структур Metrics
и Style
, их реализацию оставлю за скобками. Для начала готовим всё, что нам необходимо для анимации SC и наполнения его контентом:
public struct SegmentedControlView: View { @Binding private var selectedIndex: Int @State private var position: CGFloat = .zero @State private var dragOffset: CGFloat = 0 private let style: Style private let metrics: Metrics private let segments: [Segment] private let onSelectionChanged: (Int) -> Void public init( selectedIndex: Binding<Int>, segments: [Segment], style: Style, metrics: Metrics, onSelectionChanged: @escaping (Int) -> Void ) { self._selectedIndex = selectedIndex self.segments = segments self.style = style self.metrics = metrics self.onSelectionChanged = onSelectionChanged } }
Шаг 1. Контент SC
В нашем случае это просто горизонтальный стек из сегментов с текстом:
public struct SegmentedControlView: View { private var segmentedStack: some View { HStack(spacing: 3) { ForEach(segments.enumerated()), id: \.self) { segment in let isSelected = segments[selectedIndex] == segment SegmentView( isSelected: isSelected, title: segment.title, foregroundColor: isSelected ? .black : .white, animation: style.animation ) } } .padding(.vertical, 6) } }
Шаг 2. Отдельно вынесем background
В отдельное вычисляемое свойство положим задний фон SC:
public struct SegmentedControlView: View { private var background: some View { Capsule() .fill(style.backgroundColor) .fixedSize(horizontal: false, vertical: false) } }
Шаг 3. Пишем слайдер
Для его реализации нам понадобится дополнительная информация. А именно:
-
Ширина слайдера.
-
Ширина всего SC, чтобы ограничивать движение слайдера.
-
Высота всего SC, чтобы обеспечить вертикальные отступы слайдера от контента.
-
Ширина сегмента, чтобы корректно обрабатывать
DragGesture()
, так как она может отличаться от ширины слайдера.
public struct SegmentedControlView: View { private func slider( width: CGFloat, wholeWidth: CGFloat, height: CGFloat, segmentWidth: CGFloat ) -> some View { Capsule() .fill(style.sliderColor) .frame( width: width, // 1 height: max(0, height - metrics.sliderVerticalOffset * 2) ) // 2 .offset( x: min( max(position + dragOffset, metrics.sliderHorizontalOffset), calculateTrailingStopPointForSlider( width: wholeWidth, sliderWidth: width ) ) ) .animation(style.animation, value: dragOffset) .animation(style.animation, value: position) .gesture(gesture(segmentWidth: segmentWidth)) } }
Обслуживающие методы
private func gesture(segmentWidth: CGFloat) -> some Gesture { DragGesture() .onChanged { value in dragOffset = value.translation.width } .onEnded { _ in let segmentIndex = Int(((position + dragOffset) / segmentWidth).rounded()) let clampedIndex = clamp(index: segmentIndex) updatePosition(index: clampedIndex, segmentWidth: segmentWidth) dragOffset = 0 } } private func updatePosition( index: Int, segmentWidth: CGFloat ) { updateSelection(with: index) position = calculatePositionOfSlider( segmentWidth: segmentWidth, segmentIndex: index ) } private func calculatePositionOfSlider( segmentWidth: CGFloat, segmentIndex: Int ) -> CGFloat { let cgSegmentIndex = CGFloat(segmentIndex) let widthOfSpacersBetweenSegments = metrics.interitemSpacing * cgSegmentIndex return segmentWidth * cgSegmentIndex + widthOfSpacersBetweenSegments + metrics.sliderHorizontalOffset } private func clamp(index: Int) -> Int { max(0, min(index, segments.count - 1)) } private func calculateTrailingStopPointForSlider( width: CGFloat, sliderWidth: CGFloat ) -> CGFloat { width - sliderWidth - viewModel.metrics.sliderHorizontalOffset } }
Несколько пояснений:
-
В силу особенностей лэйаут процессов
SwiftUI
,GeometryReader
, который мы будем использовать для получения размера нашего SC, не сразу получает корректный размер. В какой-то момент ширина и высота прокси равны нулю. А поскольку, чтобы получить высоту слайдера, надо из всей высоты вычесть какое-то положительное число, в какой-то моментheight - metrics.sliderVerticalOffset * 2
будет меньше нуля, на что может ругнуться Xcode. Так что решение простое донельзя) -
Наш слайдер не может уехать за пределы SC, так что слева его ограничивает отступ от края, а справа — крайняя точка, вычисляемая по определённой формуле. Поскольку anchor point слайдера всё ещё в левом верхнем углу, этот самый верхний угол не может уезжать дальше начала последнего сегмента за вычетом расстояния от края контента. Это и просчитывается в методе
calculateTrailingStopPointForSlider
.
Шаг 4. Пишем переключение выбранного сегмента по клику
Да, пока клик по сегменту ни к чему не приводит. Это можно починить двумя способами:
-
Вам повезло, и минимальная ОС, которую вы поддерживаете, это 16.0. В таком случае для вас Apple подготовила вот такой удобный метод:
.onTapGesture { location in .... }
-
Вам повезло не так сильно. Похожий метод придётся написать самому:
Пример
import SwiftUI struct ClickGesture: Gesture { typealias Value = SimultaneousGesture<TapGesture, DragGesture>.Value let count: Int let coordinateSpace: CoordinateSpace init(count: Int = 1, coordinateSpace: CoordinateSpace = .local) { self.count = count self.coordinateSpace = coordinateSpace } var body: SimultaneousGesture<TapGesture, DragGesture> { SimultaneousGesture( TapGesture(count: count), DragGesture( minimumDistance: 0, coordinateSpace: coordinateSpace ) ) } func onEnded( perform action: @escaping (CGPoint) -> Void ) -> _EndedGesture<ClickGesture> { onEnded { simultaniousGesture in guard simultaniousGesture.first != nil, let startLocation = simultaniousGesture.second?.startLocation, let endLocation = simultaniousGesture.second?.location, startAndEndClickLocationAreTheSame(startLocation: startLocation, endLocation: endLocation) else { return } action(startLocation) } } private func startAndEndClickLocationAreTheSame( startLocation: CGPoint, endLocation: CGPoint ) -> Bool { ((startLocation.x - 1)...(startLocation.x + 1)).contains(endLocation.x) && ((startLocation.y - 1)...(startLocation.y + 1)).contains(endLocation.y) } } extension View { public func onClickGesture( count: Int = 1, coordinateSpace: CoordinateSpace = .local, perform action: @escaping (CGPoint) -> Void ) -> some View { gesture( ClickGesture(count: count, coordinateSpace: coordinateSpace) .onEnded(perform: action) ) } }
Так как минимальная поддерживаемая ОС для приложения Додо Пиццы — 15.0, мы использовали код из примера выше.
Теперь давайте используем наше решение. Модификатор onClickGesture
накинем на background
. Строго говоря, можно накинуть и на стек с контентом, но исходя из иерархии слоёв, которая будет продемонстрирована далее, удобнее будет накинуть на background
.
public struct SegmentedControlView: View { private var background: some View { Capsule() .fill(style.backgroundColor) .fixedSize(horizontal: false, vertical: false) .onClickGesture { point in let touchedX = point.x let segmentNumber = Int(touchedX / segmentWidth) let clampedIndex = clamp(index: segmentNumber) updatePosition(index: clampedIndex, segmentWidth: segmentWidth) } } }
Шаг 5. Изначальное позиционирование слайдера
Когда вьюха только появляется, надо как-то показать изначально выбранный сегмент. Учитывая, что мы тут всё считаем ручками, простой .onAppear
нам не подойдёт, поскольку он не сообщает информацию о размере вью.
Более того, считать размеры в момент появления вью тоже не всегда правильно в силу особенностей лэйаут процесса SwiftUI
. Поэтому мы напишем свой метод, который будет предоставлять нам размер вью при его изменении.
Пример
extension View { public func readSize(onChange: @escaping (CGSize) -> Void) -> some View { background( GeometryReader { geometryProxy in Color.clear .preference(key: SizePreferenceKey.self, value: geometryProxy.size) } ) .onPreferenceChange(SizePreferenceKey.self, perform: onChange) } } private struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { } }
Используем его!
public struct SegmentedControlView: View { public var body: some View { <Our hierarchy> .readSize { size in let segmentWidth = calculateSegmentWidth(wholeWidth: size.width) updatePosition( index: viewModel.selectionIndex, segmentWidth: segmentWidth ) } } private func calculateSegmentWidth(wholeWidth: CGFloat) -> CGFloat { let segmentsCount = CGFloat(segments.count) let wholeWidthWithoutSpacers = wholeWidth - viewModel.metrics.interitemSpacing * (segmentsCount - 1) return wholeWidthWithoutSpacers / segmentsCount }
Однако можно заметить, что при появлении — вместо предварительного выбора сегмента — слайдер анимировано перемещается на выбранный сегмент. Чтобы это решить, надо обновлять позицию слайдера из .readSize
без анимации, делов-то!
Добавим новое свойство, которое будет отвечать за то, анимируем мы обновление позиции или нет.
public struct SegmentedControlView: View { @State private var shouldAnimatePosition = false public var body: some View { <Our hierarchy> .readSize { size in let segmentWidth = calculateSegmentWidth(wholeWidth: size.width) updatePosition( animated: false, index: viewModel.selectionIndex, segmentWidth: segmentWidth ) } } private func updatePosition( animated: Bool = true, index: Int, segmentWidth: CGFloat ) { shouldAnimatePosition = animated updateSelection(with: index) position = calculatePositionOfSlider( segmentWidth: segmentWidth, segmentIndex: index ) } }
Замечательно! Мы добились того, что у нас было изначально с SC, написанным на UIKit
:
А теперь давайте к самому интересному — к смешиванию цветов.
Смешиваем цвета
Что такое blending? Если коротко, это некоторые стратегии смешивания цветов, применяя которые можно добиться различных эффектов.
Каждый цвет можно представить в RGBA-формате. Так вот blending просто применяет определённые манипуляции над каждой (или нет) компонентой двух цветов и получает новый цвет.
Вот шпаргалка по тому, как конкретно это всё работает.
Наша история предполагает использование .blendingMode(.difference)
, но если бы всё было так просто, я бы эту статью не писал…
Давайте попробуем накинуть на SegmentView
наш .blendingMode(.difference)
: выставим каждому сегменту белый цвет текста и посмотрим, как преобразуется белый цвет на белом фоне.
struct SegmentView: View { let isSelected: Bool let title: String let foregroundColor: Color let animation: Animation var body: some View { content .blendMode(.difference) .animation(animation, value: isSelected) } var content: some View { Text(title) .lineLimit(1) .foregroundStyle(foregroundColor) .padding(.vertical, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .fixedSize(horizontal: false, vertical: false) } }
Как мы можем видеть, выбранный сегмент ведёт себя правильно — красится в чёрный на белом фоне-слайдере, а вот не выбранные сегменты красятся в странные цвета. Естественно, белый цвет сегмента, смешиваясь с почти прозрачным чёрным по стратегии .difference
, будет давать такой результат.
Поиграв немного с блендингами, мы нашли следующее решение:
struct SegmentView: View { let isSelected: Bool let title: String let foregroundColor: Color let animation: Animation var body: some View { content .blendMode(.difference) // Решение .overlay( content.blendMode(.overlay) ) .animation(animation, value: isSelected) } var content: some View { Text(title) .lineLimit(1) .foregroundStyle(foregroundColor) .padding(.vertical, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .fixedSize(horizontal: false, vertical: false) } }
Новый слой контента с blendMode(.overlay)
повышает контрастность — цвета становятся более яркими.
Бинго! Или же… Тут я вспомнил, что люблю пиццу с креветками, зашёл на её карточку и увидел это:
На более светлом фоне цвета смешиваются неправильно. Моя любовь к креветкам спасла нас от бага.
После исследования возможных вариантов решения проблемы был выбран следующий: у нас будет два стека с контентом.
Структура будет выглядеть так:
-
Первый слой — стек с контентом, который будет служить «подложкой». Он будет чисто чёрный, без блендингов.
-
Второй слой — слайдер.
-
Третий слой — настоящий стек с контентом с белым текстом.
В итоге у нас получится два сегмента. Первый будет лежать на «подложке» из второго — полностью чёрного и без блендингов —, пока пользователь его не выберет. Так первый не будет смешиваться с почти прозрачным чёрным бэкграундом.
А когда сегмент выбран, он будет лежать на белом слайдере, меняя свой цвет на чёрный за счёт blendMode(.difference)
:
struct SegmentView: View { let isSelected: Bool let title: String let foregroundColor: Color let contentBlendMode: BlendMode let firstLevelBlendMode: BlendMode let animation: Animation var body: some View { content .blendMode(contentBlendMode) .overlay( content.blendMode(firstLevelBlendMode) ) .animation(animation, value: isSelected) } var content: some View { Text(title) .lineLimit(1) .foregroundStyle(foregroundColor) .padding(.vertical, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .fixedSize(horizontal: false, vertical: false) } }
import SwiftUI public struct SegmentedControlView: View { .... public var body: some View { segmentedStack(isSublayer: false) .background( GeometryReader { proxy in let width = proxy.size.width let segmentWidth = calculateSegmentWidth(wholeWidth: width) let sliderWidth = segmentWidth - metrics.sliderHorizontalOffset * 2 ZStack(alignment: .leading) { background(segmentWidth: segmentWidth) .overlay(segmentedStack(isSublayer: true)) // Здесь slider( width: sliderWidth, wholeWidth: width, height: proxy.size.height, segmentWidth: segmentWidth ) } } ) .readSize { size in let segmentWidth = calculateSegmentWidth(wholeWidth: size.width) updatePosition(animated: false, index: selectedIndex, segmentWidth: segmentWidth) } .onChange(of: selectedIndex) { newValue in onSelectionChanged(newValue) } } // MARK: Components private func segmentedStack(isSublayer: Bool) -> some View { HStack(spacing: 3) { ForEach(segments, id: \.self) { segment in SegmentView( isSelected: segments[selectedIndex] == segment, title: segment.title, foregroundColor: isSublayer ? .black : .white, contentBlendMode: isSublayer ? .normal : .difference, firstLevelBlendMode: isSublayer ? .normal : .overlay, animation: style.animation ) } } .padding(.vertical, 6) }
Вуаля! Теперь мы точно попали в цель.
Весь код
// MARK: - SegmentedControlView public struct Segment: Equatable, Hashable { let title: String } public struct SegmentedControlView: View { @Binding private var selectedIndex: Int @State private var position: CGFloat = .zero @State private var dragOffset: CGFloat = 0 @State private var shouldAnimatePosition: Bool = false private let metrics: Metrics private let style: Style private let segments: [Segment] private let onSelectionChanged: (Int) -> Void public init( selectedIndex: Binding<Int>, segments: [Segment], style: Style, metrics: Metrics, onSelectionChanged: @escaping (Int) -> Void ) { self._selectedIndex = selectedIndex self.segments = segments self.style = style self.metrics = metrics self.onSelectionChanged = onSelectionChanged } public var body: some View { segmentedStack(isSublayer: false) .background( GeometryReader { proxy in let width = proxy.size.width let segmentWidth = calculateSegmentWidth(wholeWidth: width) let sliderWidth = segmentWidth - metrics.sliderHorizontalOffset * 2 ZStack(alignment: .leading) { background(segmentWidth: segmentWidth) .overlay(segmentedStack(isSublayer: true)) slider( width: sliderWidth, wholeWidth: width, height: proxy.size.height, segmentWidth: segmentWidth ) } } ) .onAppearReadingSize { size in let segmentWidth = calculateSegmentWidth(wholeWidth: size.width) updatePosition(animated: false, index: selectedIndex, segmentWidth: segmentWidth) } .onChange(of: selectedIndex) { newValue in onSelectionChanged(newValue) } } // MARK: Components private func background(segmentWidth: CGFloat) -> some View { Capsule() .fill(style.backgroundColor) .fixedSize(horizontal: false, vertical: false) .onClickGesture { point in let touchedX = point.x let segmentNumber = Int(touchedX / segmentWidth) let clampedIndex = clamp(index: segmentNumber) updatePosition(index: clampedIndex, segmentWidth: segmentWidth) } } private func segmentedStack(isSublayer: Bool) -> some View { HStack(spacing: 3) { ForEach(segments, id: \.self) { segment in let isSelected = segments[selectedIndex] == segment SegmentView( isSelected: isSelected, title: segment.title, foregroundColor: isSublayer ? .black : .white, contentBlendMode: isSublayer ? .normal : .difference, firstLevelBlendMode: isSublayer ? .normal : .overlay, animation: style.animation ) } } .padding(.vertical, 6) } private func slider( width: CGFloat, wholeWidth: CGFloat, height: CGFloat, segmentWidth: CGFloat ) -> some View { Capsule() .fill(style.sliderColor) .frame(width: width, height: max(0, height - metrics.sliderVerticalOffset * 2)) .offset( x: min( max(position + dragOffset, metrics.sliderHorizontalOffset), calculateTrailingStopPointForSlider(width: wholeWidth, sliderWidth: width) ) ) .animation(style.animation, value: dragOffset) .animation(shouldAnimatePosition ? style.animation : nil, value: position) .gesture(gesture(segmentWidth: segmentWidth)) } private func gesture(segmentWidth: CGFloat) -> some Gesture { DragGesture() .onChanged { value in dragOffset = value.translation.width } .onEnded { _ in let segmentIndex = Int(((position + dragOffset) / segmentWidth).rounded()) let clampedIndex = clamp(index: segmentIndex) updatePosition(index: clampedIndex, segmentWidth: segmentWidth) dragOffset = 0 } } // MARK: Calculations private func calculateTrailingStopPointForSlider(width: CGFloat, sliderWidth: CGFloat) -> CGFloat { width - sliderWidth - metrics.sliderHorizontalOffset } private func calculateSegmentWidth(wholeWidth: CGFloat) -> CGFloat { let segmentsCount = CGFloat(segments.count) let wholeWidthWithoutSpacers = wholeWidth - metrics.interitemSpacing * (segmentsCount - 1) return wholeWidthWithoutSpacers / segmentsCount } private func clamp(index: Int) -> Int { max(0, min(index, segments.count - 1)) } private func updatePosition(animated: Bool = true, index: Int, segmentWidth: CGFloat) { shouldAnimatePosition = animated selectedIndex = index position = calculatePositionOfSlider(segmentWidth: segmentWidth, segmentIndex: index) } private func calculatePositionOfSlider(segmentWidth: CGFloat, segmentIndex: Int) -> CGFloat { let cgSegmentIndex = CGFloat(segmentIndex) let widthOfSpacersBetweenSegments = metrics.interitemSpacing * cgSegmentIndex return segmentWidth * cgSegmentIndex + widthOfSpacersBetweenSegments + metrics.sliderHorizontalOffset } }
import SwiftUI struct SegmentView: View { let isSelected: Bool let title: String let foregroundColor: Color let contentBlendMode: BlendMode let firstLevelBlendMode: BlendMode let animation: Animation var body: some View { content .blendMode(contentBlendMode) .overlay( content.blendMode(firstLevelBlendMode) ) .animation(animation, value: isSelected) } var content: some View { Text(title) .lineLimit(1) .foregroundStyle(foregroundColor) .padding(.vertical, 10) .frame(maxWidth: .infinity, maxHeight: .infinity) .fixedSize(horizontal: false, vertical: false) } }
Вместо заключения
Естественно, это просто набросок для демонстрации идеи. Оставляю на Вас, дорогой читатель, задачу модифицировать и кастомизировать этот код под свой проект или же просто позаимствовать пару идей и написать своё решение.
В следующих частях я буду развивать эту идею, прикручивая разнообразные анимашки, а также делая интерфейс более доступным. Не стесняйтесь экспериментировать и попробуйте пиццу с креветками, она у нас замечательная. До новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/867086/
Добавить комментарий