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