Собственный Segmented Control на SwiftUI. Часть 1. Смешиваем цвета

от автора

Введение

У каждого iOS-разработчика рано или поздно появляется мысль: «А как же SwiftUI? Надо бы уже переходить на него — за ним будущее». Мы в Додо давно приняли эту мысль и постепенно встраиваем SwiftUI в свою дизайн-систему.

Как известно, SwiftUI — отличный фреймворк, чтобы набросать скелет компонента, а потом три дня его дебажить. Так вот: чтобы вам не пришлось проходить этот тернистый путь отладки, это сделали мы.

Всем привет! Меня зовут Михаил Андреев, я iOS-разработчик в Додо Пицце. Сегодня я научу вас смешивать цвета)

image.png

Без фотографии Боба Росса тут никак, ну вы же понимаете (прим. ред.)

Проблема

Прямо сейчас у нас проходит 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. Пишем слайдер

Для его реализации нам понадобится дополнительная информация. А именно:

  1. Ширина слайдера.

  2. Ширина всего SC, чтобы ограничивать движение слайдера.

  3. Высота всего SC, чтобы обеспечить вертикальные отступы слайдера от контента.

  4. Ширина сегмента, чтобы корректно обрабатывать 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     } }

Несколько пояснений:

  1. В силу особенностей лэйаут процессов SwiftUI, GeometryReader, который мы будем использовать для получения размера нашего SC, не сразу получает корректный размер. В какой-то момент ширина и высота прокси равны нулю. А поскольку, чтобы получить высоту слайдера, надо из всей высоты вычесть какое-то положительное число, в какой-то момент height - metrics.sliderVerticalOffset * 2 будет меньше нуля, на что может ругнуться Xcode. Так что решение простое донельзя)

  2. Наш слайдер не может уехать за пределы SC, так что слева его ограничивает отступ от края, а справа — крайняя точка, вычисляемая по определённой формуле. Поскольку anchor point слайдера всё ещё в левом верхнем углу, этот самый верхний угол не может уезжать дальше начала последнего сегмента за вычетом расстояния от края контента. Это и просчитывается в методе calculateTrailingStopPointForSlider.

Шаг 4. Пишем переключение выбранного сегмента по клику

Да, пока клик по сегменту ни к чему не приводит. Это можно починить двумя способами:

  1. Вам повезло, и минимальная ОС, которую вы поддерживаете, это 16.0. В таком случае для вас Apple подготовила вот такой удобный метод:

.onTapGesture { location in     .... }
  1. Вам повезло не так сильно. Похожий метод придётся написать самому:

Пример
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) повышает контрастность — цвета становятся более яркими.

Бинго! Или же… Тут я вспомнил, что люблю пиццу с креветками, зашёл на её карточку и увидел это:

На более светлом фоне цвета смешиваются неправильно. Моя любовь к креветкам спасла нас от бага.

После исследования возможных вариантов решения проблемы был выбран следующий: у нас будет два стека с контентом.

Структура будет выглядеть так:

  1. Первый слой — стек с контентом, который будет служить «подложкой». Он будет чисто чёрный, без блендингов.

  2. Второй слой — слайдер.

  3. Третий слой — настоящий стек с контентом с белым текстом.

В итоге у нас получится два сегмента. Первый будет лежать на «подложке» из второго — полностью чёрного и без блендингов —, пока пользователь его не выберет. Так первый не будет смешиваться с почти прозрачным чёрным бэкграундом.

А когда сегмент выбран, он будет лежать на белом слайдере, меняя свой цвет на чёрный за счёт 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *