Connect with us

Разработка

Горизонтальный селектор/слайдер/линейка на SwiftUI

Вот что у нас получится — горизонтальный селектор/слайдер/линейка, или как вы его там называете.

Опубликовано

/

     
     

Что мы сделаем в этой статье? Откройте приложение «Фото», выберите фотографию и попробуйте её отредактировать.

Горизонтальный селектор/слайдер/линейка на SwiftUI

Вот что у нас получится — горизонтальный селектор/слайдер/линейка, или как вы его там называете. Сегментированные деления с анимацией, тактильными эффектами и опциональным отображением меток.

В интернете есть несколько решений. Однако ни одно из них не делало того, чего я хочу, и не было реализовано так, как мне нравится. И, как вы, возможно, уже знаете меня, если мне не понравится то, что есть, я просто ДЕЛАЮ ЭТО САМ.

Идея довольно проста, но есть много мелочей, на которые нужно обратить внимание, чтобы всё работало гладко, так как нам (или, по крайней мере, мне) нравится.

Давайте посмотрим.

Базовый горизонтальный пикер в виде колеса

Начнём с базового горизонтального селектора, реализованного с помощью ScrollView. На самом деле, это всего лишь небольшой вариант того, что я ранее описывал в своей статье на SwiftUI: Custom Wheel Picker.

Сначала код, затем важные моменты:

import SwiftUI

struct HorizontalDialPickerDemo: View {
    @State private var value: Double = 0
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                Text("Selected Value: \(String(format: "%.2f", value))")
                    .font(.headline)
                    .fontWeight(.semibold)
                
                HorizontalDialPicker(value: $value, range: 0...100, step: 1.0)
            }
            .navigationTitle("Horizontal Dial!")
        }
    }
}



struct HorizontalDialPicker<V>: View where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint {
    
    @Binding var value: V
    var range: ClosedRange<V>
    var step: V
    
    var tickSpacing: CGFloat = 8.0
    
    @State private var scrollPosition: Int? = nil
    
    var body: some View {
        ScrollView(.horizontal, content: {
            let totalTicks = Int((range.upperBound - range.lowerBound) / step) + 1

            HStack(spacing: tickSpacing) {

                ForEach(0..<totalTicks, id: \.self) { index in
                    RoundedRectangle(cornerRadius: 2)
                        .fill(.gray)
                        .frame(width: 2, height: 24)
                        .id(index)
                }
                
            }
            .scrollTargetLayout()
            .padding(.vertical, 16) // to extend the scrollable area vertically

        })
        .onAppear {
            self.scrollPosition = Int(value / step - range.lowerBound)
        }
        // using initial: true cannot replace onAppear
        // ie: will not set the correct initial position
        .onChange(of: value) {
            self.scrollPosition = Int(value / step - range.lowerBound)
        }
        .scrollTargetBehavior(.viewAligned(anchor: .center))
        .scrollIndicators(.hidden)
        .scrollPosition(id: $scrollPosition, anchor: .center)
        .defaultScrollAnchor(.center, for: .alignment)
        .defaultScrollAnchor(.center, for: .initialOffset)
        .defaultScrollAnchor(.center, for: .sizeChanges)
        .onChange(of: scrollPosition, {
            guard let scrollPosition = self.scrollPosition else { return }
            value = range.lowerBound + V(scrollPosition) * step
        })
    }
}


#Preview(body: {
    HorizontalDialPickerDemo()
})

Горизонтальный селектор/слайдер/линейка на SwiftUI

Ух ты, через 3 секунды мы почти закончили.

С точки зрения возможностей, мы можем обрабатывать любые типы BinaryFloatingPoint, Double, Float, CGFloat — что угодно.  Значения обновляются в зависимости от положения прокрутки. И, конечно же, даже если значение для каждого шага не целое, всё будет работать без проблем. И даже если начальное значение не равно 0.0, центр будет находиться в правильном положении.

Код может показаться довольно простым (и он действительно очень простой), однако, на самом деле, есть несколько моментов, на которые я хочу обратить внимание:

  • ScrollTargetBehavior: установите значение viewAligned, чтобы цели прокрутки всегда можно было выровнять по прямоугольнику, выровненному с геометрией представления. Я использую здесь новое бета-свойство viewAligned(anchor:), но поскольку мы просто выравниваем его по центру, старое свойство типа viewAligned будет работать так же хорошо.
  • Все эти якоря. Якорь для scrollPosition(id:anchor:) и все эти defaultScrollAnchor(_:for:)! Это необходимо для правильной установки начального положения. Сейчас это может быть сложно реализовать, но после того, как мы добавим специальную галочку для выбранного значения, это станет ОЧЕНЬ очевидным.
  • Установите scrollPosition при появлении представления. Если вы не хотите реагировать на изменение значения, вы можете удалить onChange. Но не пытайтесь установить ScrollPosition с помощью инициализатора. Не пытайтесь установить его с помощью onChange + initial: true. Я пробовал оба варианта, но, к сожалению, ни один из них не устанавливает начальное выравнивание/значение правильно. При использовании инициализатора ScrollView вообще НЕ будет прокручен до целевой позиции и просто останется в нулевой точке. При onChange + initial: true выравнивание будет установлено неправильно, начальное значение будет выровнено по заднему краю представления.
  • + 1 при расчёте totalTicks, чтобы последние тики (деления для верхней границы) отображались корректно. Вы также можете использовать ClosedRange в цикле.
  • padding(.vertical, 16): Это просто немного расширяет прокручиваемую область по вертикали.

Теперь всё должно выглядеть хорошо, чтобы я мог беспокоиться о том, будет ли это работать или нет (пожалуйста, не пытайтесь сказать мне, что это неправильный подход).

Итак, давайте сделаем наш селектор немного лучше:

  1. Сделаем так, чтобы и нижняя, и верхняя границы заканчивались по центру.
  2. Деления! Выделенное на выбранном элементе, и, возможно, каждые пару шагов. Плюс немного анимации при прокрутке.
  3. Отобразим метки значений.
  4. Добавить тактильные эффекты (сенсорную обратную связь)

Границы, заканчивающиеся в центре

Чтобы границы заканчивались в центре, или, лучше сказать, чтобы передний и задний края ScrollView заканчивались в центре, нам нужно всего лишь добавить горизонтальные отступы.

Какого размера должны быть эти отступы? Половина размера представления.

Да! Время для GeometryReader:

struct HorizontalDialPicker<V>: View where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint {
    // ...

    @State private var viewSize: CGSize? = nil
    
    
    var body: some View {
        ScrollView(.horizontal, content: {
            // ...
        })
        // ...
        .safeAreaPadding(.horizontal, (viewSize?.width ?? 0)/2 ) // so that the start and end ends at center
        .overlay(content: {
            GeometryReader { geometry in
                if geometry.size != self.viewSize {
                    DispatchQueue.main.async {
                        self.viewSize = geometry.size
                    }
                }
                return Color.clear
            }
        })
    }
}

Прежде всего, как мы все уже знаем, GeometryReader портит макет представления при использовании в качестве контейнера. Он будет пытаться занять всё доступное пространство. Поэтому добавляем его как overlay.

Также обратите внимание, что я использую safeAreaPadding(_:) вместо обычных отступов, чтобы фактический размер ScrollView не изменился (если бы мы использовали обычные отступы, ScrollView стал бы меньше).

Также очень важно, где прикрепить этот модификатор.

К ScrollView, а не к HStack.

В противном случае поведение прокрутки просто РАЗВАЛИВАЕТСЯ.

Лучшие деления

Специальный отметка для выбранного значения

Есть несколько способов добиться этого:

  1. Изменить представление деления непосредственно в цикле ForEach, проверяя, что index == scrollPosition.
  2. Добавить специальный отметку как overlay к ScrollView с выравниванием по центру.

И, конечно же, раз уж я так прямо об этом говорю, есть пара ключевых отличий, которые стоит учесть. Давайте сначала посмотрим на код обеих версий, прежде чем рассматривать эти отличия.

Способ 1:

ForEach(0..<totalTicks, id: \.self) { index in
    let isTarget = index == scrollPosition
    
    RoundedRectangle(cornerRadius: 2)
        .fill(isTarget ? .black : .gray)
        .frame(width: 2, height: 24)
        .id(index)
        .scaleEffect(x: isTarget ? 1.2 : 1, y: isTarget ? 1.5 : 1, anchor: .bottom)
}

Здесь я масштабирую относительно bottom, но, конечно, вы также можете использовать center, если хотите, чтобы он простирался и вверх, и вниз.

Горизонтальный селектор/слайдер/линейка на SwiftUI

Способ 2:

ScrollView {
  //...
}
.overlay(alignment: .center, content: {
    RoundedRectangle(cornerRadius: 2)
        .frame(width: 2.4, height: 36)
})

Обратите внимание, что я не помещаю это в тот же оверлей, что и GeometryReader, потому что, опять же, GeometryReader портит размер и выравнивание представления.

Сравнение

Время для некоторых отличий.

  1. Подход 1 приведёт к обновлению представления текущего и предыдущего целевого прямоугольника каждый раз при прокрутке пользователем. В то время как второй подход (очевидно) этого не делает. Такое обновление при прокрутке может быть как предпочтительным, так и нет. Очевидно, что это может быть не очень хорошо с точки зрения производительности. Зато это позволит нам анимировать изменения этих меток, как мы увидим в следующем разделе.
  2. Подход 1 гарантирует правильное положение специальной метки. Что я имею в виду? Во втором подходе, если пользователь прокручивает МЕДЛЕННО и останавливается МЕДЛЕННО, привязка, определённая viewAligned, фактически не произойдёт, и наложенная метка может немного отличаться от фактической в ​​цикле ForEach.

Горизонтальный селектор/слайдер/линейка на SwiftUI

Итак, поскольку мне нужна анимация, и, как я уже упоминал выше, мне больше важен внешний вид, я выбираю Подход 1.

Специальные деления для определённых шагов

Возможно, нам понадобится небольшая специальная отметка, но менее значимая, чем та, что была выше, например, индикатор значения для каждых 10 делений (тиков).

let tickSegmentCount: Int = 10
let isSegment = index % tickSegmentCount == 0
let isTarget = index == scrollPosition

RoundedRectangle(cornerRadius: 2)
    .fill(isTarget ? .yellow : isSegment ? .black : .gray)
    .frame(width: 2, height: 24)
    .id(index)
    .scaleEffect(x: isTarget ? 1.2 : 1, y: isTarget ? 1.5 : 1, anchor: .bottom)

Горизонтальный селектор/слайдер/линейка на SwiftUI

Анимация делений

Если взглянуть на приложение «Фотографии», то к делениям добавляется небольшая анимация при прокрутке. — по высоте и цвету

Одна строчка кода на основе RoundedRectangle:

 .animation(.default, value: isTarget)

Горизонтальный селектор/слайдер/линейка на SwiftUI

Знаю, я прокручиваю страницу очень медленно. Просто чтобы мы могли увидеть анимацию.

Несколько меток для контрольных точек

Так же просто, как несколько оверлеев для нашего RoundedRectangle:

struct HorizontalDialPicker<V>: View where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint {
    // ...
    var showSegmentValueLabel: Bool = true
    var labelSignificantDigit: Int = 1
    
    var body: some View {
        ScrollView(.horizontal, content: {
            let totalTicks = Int((range.upperBound - range.lowerBound) / step) + 1
            HStack(spacing: tickSpacing) {

                ForEach(0..<totalTicks, id: \.self) { index in
                    let isSegment = index % tickSegmentCount == 0
                    let isTarget = index == scrollPosition
                    
                    RoundedRectangle(cornerRadius: 2)
                        // ...
                        .overlay(alignment: .bottom, content: {
                            if isSegment, self.showSegmentValueLabel {
                                let value = Double(range.lowerBound + V(index) * step)
                                Text("\(String(format: "%.\(labelSignificantDigit)f", value))")
                                    .font(.system(size: 12))
                                    .fontWeight(.semibold)
                                    .fixedSize() // required to avoid being cutoff horizontally
                                    .offset(y: 16)
                            }
                        })
                }
                
            }
             .padding(.vertical, 16)
            // ...
            
        })
        //...
    }
}

Горизонтальный селектор/слайдер/линейка на SwiftUI

Модификатор fixedSize здесь нужен, чтобы избежать установки ширины текста такой же, как у наложенного представления, то есть RoundedRectangle, чтобы строка отображалась полностью.

Кроме того, в зависимости от размера Text и offset, вам может потребоваться настроить вертикальный отступ, добавленный нами в HStack, чтобы представление текста не обрезалось по вертикали.

Тактильные эффекты

Я пропущу здесь объяснение тактильных эффектов и сенсорной обратной связи. Если вы хотите узнать о них больше, пожалуйста, ознакомьтесь с моей предыдущей статьей SwiftUI: Haptic Effect 2 Ways.

Но для этого мы можем включить SensoryFeedback, просто добавив модификатор представления sensoryFeedback(_:trigger:) к нашему RoundedRectangle:

RoundedRectangle(cornerRadius: 2)
    // ...
    .sensoryFeedback(.selection, trigger: isTarget)

Код выше сработает, однако есть пара «но»:

  1. Haptic эффекты будут мешать прокрутке, и мы можем начать понимать, что наше начальное значение больше не выравнивается/прокручивается правильно.
  2. Мы не хотим включать тактильные эффекты при программной прокрутке при установке начального значения.

Чтобы решить эту проблему, мы можем просто добавить ещё одну переменную состояния, которая будет отслеживать, завершена ли инициализация:

struct HorizontalDialPicker<V>: View where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint {
    //...

    // to avoid haptic effects on initialization,
    // ie: when setting self.scrollPosition in onAppear
    @State private var initialized: Bool = false

    
    var body: some View {
        ScrollView(.horizontal, content: {
            let totalTicks = Int((range.upperBound - range.lowerBound) / step) + 1
            
            HStack(spacing: tickSpacing) {
                ForEach(0..<totalTicks, id: \.self) { index in
                    let isSegment = index % tickSegmentCount == 0
                    let isTarget = index == scrollPosition
                    
                    RoundedRectangle(cornerRadius: 2)
                        // ...
                        .sensoryFeedback(.selection, trigger: isTarget && initialized)
                        
                }
                
            }
            //...
        })
        .onAppear {
            self.scrollPosition = Int(value / step - range.lowerBound)
            
            // make sure scroll finishes before enabling haptic (Sensory feedback)
            // because those feedbacks can get into the way of scrolling
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
                self.initialized = true
            })
        }
        // ...

    }
}

Код готов

Вот и всё о нашем маленьком HorizontalDialPicker.

Вот полный код, попробуйте сами:

import SwiftUI

struct HorizontalDialPickerDemo: View {
    @State private var value: Double = 50
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                Text("Selected Value: \(String(format: "%.2f", value))")
                    .font(.headline)
                    .fontWeight(.semibold)
                
                HorizontalDialPicker(value: $value, range: 0...100, step: 0.5)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
            .background(.yellow.opacity(0.1))
            .navigationTitle("Horizontal Dial!")
        }
    }
}



struct HorizontalDialPicker<V>: View where V : BinaryFloatingPoint, V.Stride : BinaryFloatingPoint {
    
    @Binding var value: V
    var range: ClosedRange<V>
    var step: V
    
    var tickSpacing: CGFloat = 8.0
    var tickSegmentCount: Int = 10
    var showSegmentValueLabel: Bool = true
    var labelSignificantDigit: Int = 1
    
    @State private var scrollPosition: Int? = nil
    @State private var viewSize: CGSize? = nil
    
    // to avoid haptic effects on initialization,
    // ie: when setting self.scrollPosition in onAppear
    @State private var initialized: Bool = false

    
    var body: some View {
        ScrollView(.horizontal, content: {
            let totalTicks = Int((range.upperBound - range.lowerBound) / step) + 1
            
            HStack(spacing: tickSpacing) {
                ForEach(0..<totalTicks, id: \.self) { index in
                    let isSegment = index % tickSegmentCount == 0
                    let isTarget = index == scrollPosition
                    
                    RoundedRectangle(cornerRadius: 2)
                        .fill(isTarget ? .yellow : isSegment ? .black : .gray)
                        .frame(width: 2, height: 24)
                        .id(index)
                        .scaleEffect(x: isTarget ? 1.2 : 1, y: isTarget ? 1.5 : 0.8, anchor: .bottom)
                        .animation(.default.speed(1.2), value: isTarget)
                        .sensoryFeedback(.selection, trigger: isTarget && initialized)
                        .overlay(alignment: .bottom, content: {
                            if isSegment, self.showSegmentValueLabel {
                                let value = Double(range.lowerBound + V(index) * step)
                                Text("\(String(format: "%.\(labelSignificantDigit)f", value))")
                                    .font(.system(size: 12))
                                    .fontWeight(.semibold)
                                    .fixedSize() // required to avoid being cutoff horizontally
                                    .offset(y: 16)
                            }
                        })
                }
                
            }
            .padding(.vertical, 16) // to extend the scrollable area vertically
            .scrollTargetLayout()
            
        })
        .onAppear {
            self.scrollPosition = Int(value / step - range.lowerBound)
            
            // make sure scroll finishes before enabling haptic (Sensory feedback)
            // because those feedbacks can get into the way of scrolling
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
                self.initialized = true
            })
        }
        // using initial: true cannot replace onAppear
        // ie: will not set the correct initial position
        .onChange(of: value) {
            self.scrollPosition = Int(value / step - range.lowerBound)
        }
        .scrollTargetBehavior(.viewAligned(anchor: .center))
        .scrollIndicators(.hidden)
        .scrollPosition(id: $scrollPosition, anchor: .center)
        .defaultScrollAnchor(.center, for: .alignment)
        .defaultScrollAnchor(.center, for: .initialOffset)
        .defaultScrollAnchor(.center, for: .sizeChanges)
        .onChange(of: scrollPosition, {
            guard let scrollPosition = self.scrollPosition else { return }
            value = range.lowerBound + V(scrollPosition) * step
        })
        .safeAreaPadding(.horizontal, (viewSize?.width ?? 0)/2 ) // so that the start and end ends at center
        .overlay(content: {
            GeometryReader { geometry in
                if geometry.size != self.viewSize {
                    DispatchQueue.main.async {
                        self.viewSize = geometry.size
                    }
                }
                return Color.clear
            }
        })
    }
}

Спасибо за прочтение и приятного вращения!

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: