Введение
У каждого 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)
}
}
Вместо заключения
Естественно, это просто набросок для демонстрации идеи. Оставляю на Вас, дорогой читатель, задачу модифицировать и кастомизировать этот код под свой проект или же просто позаимствовать пару идей и написать своё решение.
В следующих частях я буду развивать эту идею, прикручивая разнообразные анимашки, а также делая интерфейс более доступным. Не стесняйтесь экспериментировать и попробуйте пиццу с креветками, она у нас замечательная. До новых встреч!