Чтобы пользовательский элемент перетаскивания SwiftUI фиксировался на дискретных значениях, преобразуйте локальную позицию указателя в ограниченную долю, округлите её до целочисленного индекса и рисуйте бегунок по этому индексу. В этом примере для macOS выбирается один из пяти размеров круга, а выбор применяется при отпускании.
Выберите принцип взаимодействия до написания жеста
Задача читателя проста: добавить в приложение для Mac пользовательский горизонтальный селектор размера. Доступны размеры 20, 40, 60, 80 и 100 пунктов. При перетаскивании отображается предварительный выбор с фиксацией на ближайшем значении; при отпускании этот размер применяется к условному кругу. Всё хранится в памяти.
Сначала решите, нужно ли пользовательское рисование. Стандартный элемент Slider от Apple уже поддерживает дискретные шаги через параметр step и обновляет привязанное значение при перемещении бегунка. Для обычного селектора размера эти варианты можно напрямую задать диапазоном 20...100 и шагом 20. Используйте этот встроенный элемент, если его внешний вид и поведение соответствуют задаче. Документация Apple по Slider
Приведённая ниже пользовательская реализация пригодится, если нужно управлять дорожкой, делениями, бегунком и поведением при отпускании. В ней намеренно используется целочисленный индекс выбора. Отображаемый размер берётся из массива, поэтому модель не может случайно содержать промежуточное значение, например 57.3.
В этом примере важны три решения:
- Деления расположены равномерно, размеры увеличиваются слева направо.
- Бегунок и подпись значения фиксируются на ближайшем варианте во время перетаскивания.
- Выбранный размер в родительском представлении меняется при отпускании; во время предварительного просмотра круг сохраняет ранее применённый размер.
Преобразуйте позицию указателя в дискретный индекс
Геометрия ввода и рисования должна совпадать. Здесь ширина элемента управления составляет 336 пунктов, а радиус бегунка — 12 пунктов. Поэтому его центр перемещается от 12 до 324, а полезная длина дорожки составляет 312 пунктов. Если для расчёта ввода использовать полную ширину, позиции указателя и крайних делений не совпадут.
Преобразование состоит из четырёх операций: вычтите левый отступ, разделите результат на полезную длину дорожки, ограничьте диапазоном 0...1 и умножьте на число интервалов перед округлением. У пяти вариантов четыре интервала. Обратное преобразование размещает выбранный бегунок на соответствующем делении.
Следующая вспомогательная функция входит в полное приложение, которое будет собрано в следующем разделе. При точном попадании в середину она выбирает больший индекс, поскольку нормализованные индексы неотрицательны. Apple описывает .toNearestOrAwayFromZero как округление к ближайшему значению с отбрасыванием половин от нуля. Документация Apple по rounded(_:)
import SwiftUI
enum SnapGeometry {
static func index(
at x: CGFloat,
width: CGFloat,
inset: CGFloat,
count: Int
) -> Int {
guard count > 1, x.isFinite, width > 2 * inset else {
return 0
}
let fraction = min(max((x - inset) / (width - 2 * inset), 0), 1)
return Int(
(fraction * CGFloat(count - 1))
.rounded(.toNearestOrAwayFromZero)
)
}
static func position(
for index: Int,
width: CGFloat,
inset: CGFloat,
count: Int
) -> CGFloat {
guard count > 1 else { return width / 2 }
return inset + CGFloat(index) / CGFloat(count - 1)
* (width - 2 * inset)
}
}
Эти примеры показывают поведение на границах для заданной геометрии. Ограничение диапазона оставляет выбор допустимым, даже если перетаскивание выходит за любой из концов; взаимодействие не отменяется.
| Координата указателя x | Выбранный индекс | Размер в пунктах |
|---|---|---|
-10 |
0 |
20 |
12 |
0 |
20 |
51 |
1 |
40 |
90 |
1 |
40 |
168 |
2 |
60 |
324 |
4 |
100 |
350 |
4 |
100 |
В точке 51 указатель находится ровно посередине между первыми двумя делениями, поэтому правило выбирает индекс 1. Я проверил эти детерминированные вычисления с помощью Python. В среде исследования не было компилятора Swift, поэтому приведённое ниже приложение SwiftUI было проверено визуально, но не компилировалось и не запускалось.
Соберите элемент управления и родительское представление для macOS
Создайте в Xcode проект приложения для macOS на SwiftUI с целевой версией macOS 13 или новее. Замените сгенерированные объявление приложения и представление содержимого кодом ниже и добавьте предыдущую вспомогательную функцию в тот же файл Swift. Вспомогательная функция и следующие два блока вместе составляют полную программу; оставьте только одно объявление приложения @main.
Родительское представление хранит зафиксированный индекс в @State. Элемент управления получает @Binding, который связывает его с источником истины, не создавая второе постоянное состояние выбора. Это соответствует документированному Apple принципу владения привязками. Документация Apple по Binding
@main
struct SnapSizeApp: App {
var body: some Scene {
WindowGroup {
SizeDemo()
}
}
}
struct SizeDemo: View {
private let sizes = [20, 40, 60, 80, 100]
@State private var selectedIndex = 2
var body: some View {
VStack(spacing: 24) {
Text("Circle size")
.font(.headline)
Circle()
.fill(Color.accentColor)
.frame(
width: CGFloat(sizes[selectedIndex]),
height: CGFloat(sizes[selectedIndex])
)
.frame(width: 120, height: 120)
DiscreteSizeControl(
sizes: sizes,
selection: $selectedIndex
)
Text("Applied size: \(sizes[selectedIndex]) pt")
}
.padding(32)
.frame(minWidth: 420, minHeight: 340)
}
}
Для временного предварительного просмотра элемент управления использует @GestureState. Apple указывает, что состояние жеста сбрасывается к исходному значению, когда жест становится неактивным. Здесь возврат к nil заставляет элемент управления снова показывать зафиксированное значение привязки, поэтому временный выбор не нужно вручную очищать в отдельной переменной постоянного состояния. Документация Apple по GestureState
struct DiscreteSizeControl: View {
let sizes: [Int]
@Binding var selection: Int
@GestureState private var previewIndex: Int? = nil
private let trackWidth: CGFloat = 336
private let radius: CGFloat = 12
private var shownIndex: Int {
previewIndex ?? selection
}
private func snappedIndex(at x: CGFloat) -> Int {
SnapGeometry.index(
at: x, width: trackWidth,
inset: radius, count: sizes.count
)
}
private func xPosition(for index: Int) -> CGFloat {
SnapGeometry.position(
for: index, width: trackWidth,
inset: radius, count: sizes.count
)
}
private func moveSelection(by amount: Int) {
guard previewIndex == nil else { return }
selection = min(max(selection + amount, 0), sizes.count - 1)
}
var body: some View {
VStack(spacing: 10) {
Text("Selected size: \(sizes[shownIndex]) pt")
.monospacedDigit()
ZStack(alignment: .topLeading) {
Capsule()
.fill(Color.secondary.opacity(0.25))
.frame(width: trackWidth - 2 * radius, height: 4)
.position(x: trackWidth / 2, y: 20)
ForEach(sizes.indices, id: \.self) { index in
Circle()
.fill(Color.secondary)
.frame(width: 5, height: 5)
.position(x: xPosition(for: index), y: 20)
}
Circle()
.fill(Color.accentColor)
.frame(width: 2 * radius, height: 2 * radius)
.position(x: xPosition(for: shownIndex), y: 20)
}
.frame(width: trackWidth, height: 40)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.updating($previewIndex) { value, state, _ in
state = snappedIndex(at: value.location.x)
}
.onEnded { value in
selection = snappedIndex(at: value.location.x)
}
)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Circle size")
.accessibilityValue("\(sizes[shownIndex]) points")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
moveSelection(by: 1)
case .decrement:
moveSelection(by: -1)
@unknown default:
break
}
}
HStack {
Button("Smaller") { moveSelection(by: -1) }
.disabled(selection == 0 || previewIndex != nil)
Button("Larger") { moveSelection(by: 1) }
.disabled(
selection == sizes.count - 1 || previewIndex != nil
)
}
}
}
}
В этом примере явно заданы предварительные условия: массив размеров не пуст, зафиксированный индекс допустим, а массив не меняется во время взаимодействия. Родительское представление удовлетворяет всем трём условиям. Защитное возвращаемое значение вспомогательной функции геометрии не делает допустимым пустой массив для элемента управления, поскольку подпись всё равно обращается к его элементу по индексу. Если варианты берутся из изменяемой модели, проверяйте коллекцию и выбор до отображения и определите, что должно происходить при изменении вариантов во время перетаскивания.
Привяжите жест к стабильной геометрии элемента управления
Перетаскивание привязано к неподвижному контейнеру дорожки шириной 336 пунктов. Бегунок перемещается внутри контейнера. Apple определяет location как текущую позицию события и позволяет указать в DragGesture систему координат для позиций. Использование .local относительно стабильного контейнера согласует сообщаемую координату x с расчётами рисования. Документация Apple по DragGesture, документация Apple по location
Прямоугольная форма содержимого делает область дорожки доступной для проверки попадания, включая промежутки между нарисованными фигурами. Нулевое минимальное расстояние позволяет распознать жест без начального порога перемещения. В этой конструкции нажатие в любом месте дорожки сразу показывает ближайшее деление. Такой переход к позиции указателя предусмотрен намеренно. Документация Apple по contentShape(_:eoFill:), документация Apple по DragGesture
Чтобы реагировать только на бегунок и сохранять начальное смещение захвата, используйте другое преобразование: один раз зафиксируйте начальное положение центра бегунка, затем прибавьте полное горизонтальное смещение жеста и зафиксируйте результат на ближайшем делении. Не прибавляйте полное смещение снова и снова к уже обновлённому центру. SwiftUI описывает смещение как перемещение от начала жеста, а не как движение с момента предыдущего вызова обработчика. Документация Apple по translation
Не прикрепляйте жест к окружающему стеку подписей и кнопок. Распознавание зависит от представления, к которому прикреплён жест, и его дочерних представлений; расширение области может привести к конкуренции с другими элементами управления. Если вы поместите этот пример в прокручиваемое представление, отдельно проверьте распознавание жестов. Перетаскивание с нулевым минимальным расстоянием начинает действовать сразу, поэтому для начала лучше использовать отдельную панель. Документация Apple по gesture(_:including:)
Определите, когда предварительный выбор становится зафиксированным состоянием
Во время перетаскивания меняются только бегунок элемента управления и подпись выбранного значения. Большой круг и подпись применённого значения следуют за зафиксированным индексом родительского представления. При отпускании обработчик вычисляет конечный индекс по последней позиции, а не полагается на последний вызов обработчика предварительного просмотра.
Если жест становится неактивным, так и не зафиксировав выбор при отпускании, временный предварительный выбор сбрасывается, а предыдущий зафиксированный вариант сохраняется. Это поведение обеспечивается тем, что предварительный выбор хранится в @GestureState, а запись постоянного значения выполняется в обработчике завершения. Документация Apple по GestureState
Для применения в реальном времени записывайте зафиксированный индекс в привязку при каждом изменении жеста. Это меняет условия взаимодействия: промежуточные варианты становятся зафиксированными обновлениями модели. Решите, подходит ли такой подход, прежде чем связывать селектор с более сложной отрисовкой. В обоих вариантах рисуйте бегунок по тому же квантованному индексу, который используется для выбранного значения.
Кнопки Меньше и Больше сразу применяют изменения на один шаг и останавливаются на крайних значениях. Дорожка также предоставляет настраиваемое действие доступности, обработчики увеличения и уменьшения которого используют ту же функцию шага. Apple описывает этот модификатор как механизм, позволяющий вспомогательным технологиям, например VoiceOver, вызывать настраиваемые действия. Документация Apple по accessibilityAdjustableAction(_:)
Когда взаимодействием управляет представление AppKit
Для этой задачи чистому представлению SwiftUI не нужен мост к AppKit. Если дорожка уже находится в вашем собственном NSView, обрабатывайте распознавание непосредственно в этом представлении. Метод Apple addGestureRecognizer(_:) добавляет распознаватель к представлению, связывает его жест с этим представлением и его дочерними элементами и устанавливает сильную ссылку на распознаватель. Документация Apple по addGestureRecognizer(_:)
В реализации AppKit с NSPanGestureRecognizer измеряйте смещение в системе координат неподвижного представления дорожки. Зафиксируйте начальную позицию бегунка, прибавьте переданное полное смещение, а затем подайте полученную координату x в то же вычисление фиксации. Apple прямо указывает, что translation(in:) возвращает общее расстояние с начала жеста с учётом преобразования выбранного представления. Документация Apple по translation(in:)
Используйте одну реализацию жеста для каждой области взаимодействия. Если дорожка AppKit встроена в SwiftUI, пусть она сама обрабатывает перетаскивание и сообщает об изменениях выбора через связь с моделью приложения. Так сохраняется понятный путь от координат события через предварительный просмотр к зафиксированному выбору.
Проверьте крайние значения, пороги и прерванные перетаскивания
Проверьте взаимодействие в приложении для Mac с помощью короткой последовательности действий:
- Начните со среднего деления. Перетащите бегунок через каждый интервал и убедитесь, что подпись выбранного значения показывает только пять допустимых размеров.
- Переместите указатель за оба горизонтальных края. Бегунок должен оставаться на первом или последнем делении.
- Отпустите указатель между делениями. Круг должен принять ближайший размер, соответствующий конечному положению бегунка.
- Щёлкните в свободной области дорожки. Убедитесь, что переход к позиции указателя подходит для задачи.
- Прервите активное взаимодействие и проверьте, что временный предварительный выбор не остаётся на экране.
- Используйте обе кнопки пошагового изменения и настройки VoiceOver. Убедитесь, что они останавливаются на тех же крайних значениях.
Для неравномерных вариантов, например 20, 30, 60 и 100, в этом индексном подходе варианты всё равно располагаются равномерно. Это намеренный порядковый порядок. Если расстояние должно отражать числовую разницу, сопоставьте каждому числовому значению собственную позицию на дорожке и выбирайте ближайшую; округление равномерного индекса задавало бы другое поведение.
Надёжный элемент управления с фиксацией использует одну систему координат и одно преобразование для ввода и рисования. Когда они согласованы, целочисленный индекс сохраняет дискретность каждого выбранного значения, а разделение временного и зафиксированного состояний делает поведение при отпускании явным.
Источники
- Slider — Apple
- rounded(_:) — Apple
- Binding — Apple
- GestureState — Apple
- DragGesture — Apple
- location — Apple
- contentShape(_:eoFill:) — Apple
- translation — Apple
- gesture(_:including:) — Apple
- accessibilityAdjustableAction(_:) — Apple
- addGestureRecognizer(_:) — Apple
- translation(in:) — Apple