Введение

У каждого 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)
   }
}

Вместо заключения

Естественно, это просто набросок для демонстрации идеи. Оставляю на Вас, дорогой читатель, задачу модифицировать и кастомизировать этот код под свой проект или же просто позаимствовать пару идей и написать своё решение.

В следующих частях я буду развивать эту идею, прикручивая разнообразные анимашки, а также делая интерфейс более доступным. Не стесняйтесь экспериментировать и попробуйте пиццу с креветками, она у нас замечательная. До новых встреч!

Комментарии (0)