Untuk membuat kontrol seret SwiftUI kustom berhenti pada nilai diskret, ubah posisi lokal penunjuk menjadi pecahan terbatas, bulatkan ke indeks bilangan bulat, lalu gambar kenop berdasarkan indeks tersebut. Contoh macOS ini memilih satu dari lima ukuran lingkaran dan menerapkan pilihan saat dilepas.
Tentukan interaksi sebelum menulis gestur
Tugas pembaca cukup sederhana: menambahkan pemilih ukuran horizontal kustom ke aplikasi Mac. Ukuran yang tersedia adalah 20, 40, 60, 80, dan 100 poin. Menyeret akan menampilkan pratinjau pilihan yang dibulatkan; melepas akan menerapkan ukuran itu ke sebuah lingkaran sintetis. Semuanya disimpan di memori.
Pertama, tentukan apakah penggambaran kustom memang diperlukan. Slider standar Apple sudah mendukung kenaikan diskret melalui parameter step dan memperbarui nilai terikatnya saat kenop bergerak. Untuk pemilih ukuran biasa, rentang 20...100 dengan langkah 20 menyatakan pilihan ini secara langsung. Gunakan kontrol yang didukung tersebut jika tampilan dan interaksinya sesuai dengan kebutuhan. Dokumentasi Slider Apple
Implementasi kustom di bawah berguna ketika Anda perlu mengatur trek, tanda skala, kenop, dan perilaku saat dilepas. Implementasi ini sengaja menggunakan indeks pilihan bilangan bulat. Ukuran yang ditampilkan berasal dari sebuah larik, sehingga model tidak mungkin secara tidak sengaja menyimpan ukuran antara seperti 57.3.
Tiga keputusan mendefinisikan contoh ini:
- Posisi tanda skala berjarak sama, dengan ukuran yang lebih besar di sebelah kanan.
- Kenop dan label nilai kontrol sama-sama mengikuti pilihan yang dibulatkan selama penyeretan.
- Ukuran yang dipilih di tampilan induk berubah saat dilepas; lingkaran tetap menggunakan ukuran yang sebelumnya diterapkan selama pratinjau.
Ubah posisi penunjuk menjadi indeks diskret
Selaraskan geometri masukan dan geometri gambar. Di sini, lebar kontrol adalah 336 poin dan radius kenop 12 poin. Karena itu, pusatnya bergerak dari 12 hingga 324, sehingga panjang trek yang dapat digunakan adalah 312 poin. Jika lebar penuh digunakan dalam perhitungan masukan, posisi penunjuk dan tanda skala ujung tidak akan selaras.
Pemetaan ini memiliki empat operasi: kurangi inset kiri, bagi dengan panjang trek yang dapat digunakan, batasi ke rentang 0...1, lalu kalikan dengan jumlah interval sebelum pembulatan. Lima pilihan memiliki empat interval. Pemetaan kebalikannya menempatkan kenop yang dipilih pada tanda skala yang sesuai.
Fungsi pembantu berikut merupakan bagian dari aplikasi lengkap yang dirangkai di bagian selanjutnya. Kebijakan pembulatannya memilih indeks yang lebih tinggi tepat di titik tengah karena indeks ternormalisasi bernilai nonnegatif. Swift menjelaskan .toNearestOrAwayFromZero sebagai pembulatan ke nilai terdekat dengan kasus seri menjauhi nol. Dokumentasi rounded(_:) Apple
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)
}
}
Masukan ilustratif ini menunjukkan perilaku batas untuk geometri tetap. Pembatasan memastikan penyeretan yang melewati salah satu ujung tetap berada pada pilihan yang valid; interaksinya tidak dibuang.
| Koordinat x penunjuk | Indeks terpilih | Ukuran dalam poin |
|---|---|---|
-10 |
0 |
20 |
12 |
0 |
20 |
51 |
1 |
40 |
90 |
1 |
40 |
168 |
2 |
60 |
324 |
4 |
100 |
350 |
4 |
100 |
Pada 51, penunjuk tepat berada di tengah-tengah antara dua tanda skala pertama, sehingga aturan memilih indeks 1. Saya memeriksa perhitungan deterministik ini dengan Python. Kompiler Swift tidak tersedia di lingkungan riset, jadi aplikasi SwiftUI di bawah telah diperiksa tetapi belum dikompilasi atau dijalankan di sini.
Buat kontrol macOS dan tampilan induknya
Buat proyek App macOS di Xcode menggunakan SwiftUI dan target deployment macOS 13 atau lebih baru. Ganti deklarasi aplikasi dan tampilan konten yang dihasilkan dengan kode di bawah, lalu sertakan fungsi pembantu sebelumnya dalam berkas Swift yang sama. Fungsi pembantu dan dua blok berikutnya membentuk satu program lengkap; gunakan hanya satu deklarasi aplikasi @main.
Tampilan induk memiliki indeks yang diterapkan di @State. Kontrol menerima @Binding, yang menghubungkannya ke sumber kebenaran tersebut tanpa membuat pilihan persisten kedua. Ini mengikuti pola kepemilikan binding yang didokumentasikan Apple. Dokumentasi Binding Apple
@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)
}
}
Kontrol menggunakan @GestureState untuk pratinjau sementara. Apple menjelaskan bahwa status gestur diatur ulang ke nilai awalnya saat gestur tidak lagi aktif. Di sini, kembali ke nil membuat kontrol menampilkan binding yang sudah diterapkan lagi, sehingga pilihan sementara tidak memerlukan pembersihan manual pada variabel persisten terpisah. Dokumentasi GestureState Apple
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
)
}
}
}
}
Contoh ini memiliki prasyarat eksplisit: larik ukuran tidak kosong, indeks yang diterapkan valid, dan larik tetap sama selama interaksi berlangsung. Tampilan induk yang diberikan memenuhi ketiganya. Nilai balik defensif fungsi pembantu geometri tidak menjadikan larik kosong valid untuk kontrol, karena label tetap mengindeks larik tersebut. Jika pilihan berasal dari model yang dapat berubah, validasi koleksi dan pilihan sebelum merender, lalu tentukan apa yang terjadi jika pilihan berubah saat penyeretan berlangsung.
Pasang gestur pada geometri kontrol yang stabil
Seretan dipasang pada wadah trek 336 poin yang tidak bergerak. Kenop bergerak di dalam wadah tersebut. Apple mendefinisikan location sebagai lokasi peristiwa saat ini dan memungkinkan DragGesture menentukan ruang koordinat yang digunakan untuk lokasi. Menggunakan .local pada wadah yang stabil membuat koordinat x yang dilaporkan sesuai dengan perhitungan gambar. Dokumentasi DragGesture Apple, dokumentasi location Apple
Bentuk konten persegi panjang membuat area trek kontrol dapat digunakan untuk pengujian ketukan, termasuk ruang di antara bentuk yang digambar. Jarak minimum nol memungkinkan pengenalan gestur tanpa ambang perpindahan awal. Dalam desain ini, menekan bagian mana pun pada trek langsung menampilkan pratinjau tanda skala terdekat. Perilaku berpindah ke posisi itu memang disengaja. Dokumentasi contentShape(_:eoFill:) Apple, dokumentasi DragGesture Apple
Untuk interaksi yang hanya menggunakan kenop dan mempertahankan offset awal saat digenggam, gunakan pemetaan lain: ambil posisi awal pusat kenop sekali, lalu tambahkan translasi horizontal total gestur sebelum membulatkannya. Jangan terus menambahkan translasi total ke pusat yang sudah diperbarui. Swift menjelaskan translasi sebagai perpindahan dari awal gestur, bukan pergerakan sejak callback sebelumnya. Dokumentasi translation Apple
Jangan pasang gestur pada tumpukan label dan tombol di sekitarnya. Pemasangan gestur memengaruhi hubungan pengenalan dengan tampilan yang dipasangi dan subtampilannya; memperluas cakupan itu dapat menimbulkan persaingan dengan kontrol lain. Jika contoh ini disematkan dalam tampilan gulir, uji pengenalan gesturnya secara khusus. Seretan dengan jarak nol adalah interaksi langsung yang disengaja, jadi panel mandiri merupakan titik awal yang paling jelas. Dokumentasi gesture(_:including:) Apple
Tentukan kapan pratinjau menjadi status yang diterapkan
Selama penyeretan, hanya kenop kontrol dan label nilai terpilih yang berubah. Lingkaran yang lebih besar dan label nilai yang diterapkannya mengikuti indeks yang sudah diterapkan di tampilan induk. Saat dilepas, penangan menghitung indeks akhir dari lokasi akhir, alih-alih mengandalkan callback pratinjau terbaru.
Jika gestur menjadi tidak aktif tanpa penerapan saat dilepas, pratinjau sementara diatur ulang dan pilihan yang sebelumnya diterapkan tetap berlaku. Perilaku ini terjadi karena status pratinjau disimpan di @GestureState dan penulisan persisten ditempatkan di penangan akhir. Dokumentasi GestureState Apple
Untuk menerapkan perubahan langsung, tulis indeks yang sudah dibulatkan ke binding selama perubahan seretan. Ini mengubah kontrak interaksi: pilihan perantara menjadi pembaruan model yang diterapkan. Tentukan apakah itu diinginkan sebelum menghubungkan pemilih ke proses rendering yang lebih besar. Pada kedua desain, gambar kenop dari indeks terkuantisasi yang sama dengan nilai terpilih.
Tombol Smaller dan Larger langsung menerapkan perubahan satu langkah dan berhenti di ujung. Trek juga menyediakan aksi aksesibilitas yang dapat disesuaikan; penangan kenaikan dan penurunannya menggunakan fungsi langkah yang sama. Apple menjelaskan modifier ini sebagai mekanisme bagi teknologi bantu seperti VoiceOver untuk menjalankan aksi yang dapat disesuaikan. Dokumentasi accessibilityAdjustableAction(_:) Apple
Saat tampilan AppKit menangani interaksi
Tampilan SwiftUI murni tidak memerlukan jembatan AppKit untuk tugas ini. Jika trek sudah berada dalam NSView milik Anda, tangani pengenalan gestur di tampilan tersebut. addGestureRecognizer(_:) dari Apple memasang pengenal gestur pada tampilan, membatasi gestur yang diwakilinya pada tampilan tersebut dan subtampilannya, serta membuat referensi kuat ke pengenal gestur itu. Dokumentasi addGestureRecognizer(_:) Apple
Untuk implementasi AppKit yang menggunakan NSPanGestureRecognizer, ukur translasi dalam sistem koordinat tampilan trek yang tidak bergerak. Ambil posisi awal kenop, tambahkan translasi total yang dilaporkan, lalu teruskan koordinat x hasilnya ke perhitungan pembulatan yang sama. Apple secara eksplisit menjelaskan bahwa translation(in:) mengembalikan jarak total sejak gestur dimulai, dengan transformasi tampilan yang dipilih diterapkan. Dokumentasi translation(in:) Apple
Gunakan satu implementasi gestur untuk setiap permukaan interaksi. Jika trek AppKit disematkan di SwiftUI, biarkan trek tersebut menangani geser dan melaporkan perubahan pilihan melalui koneksi model aplikasi. Dengan begitu, jalur dari koordinat peristiwa ke pratinjau hingga pilihan yang diterapkan tetap jelas.
Periksa titik ujung, ambang, dan seretan yang terhenti
Uji interaksi di aplikasi Mac dengan urutan singkat dan konkret:
- Mulai dari tanda skala tengah. Seret melewati setiap interval dan pastikan label pilihan hanya menampilkan lima ukuran yang diizinkan.
- Gerakkan penunjuk melewati kedua ujung horizontal. Kenop harus tetap berada pada tanda skala pertama atau terakhir.
- Lepaskan di antara tanda skala. Lingkaran harus menggunakan ukuran terdekat yang sesuai dengan posisi akhir kenop.
- Klik bagian trek yang tidak ditempati. Pastikan perilaku berpindah ke posisi itu sesuai dengan kebutuhan.
- Hentikan interaksi yang sedang berlangsung dan pastikan tidak ada pratinjau sementara yang tertinggal.
- Gunakan kedua tombol langkah dan penyesuaian VoiceOver. Pastikan semuanya berhenti di titik ujung yang sama.
Untuk pilihan tidak merata seperti 20, 30, 60, dan 100, desain berbasis indeks ini tetap memberi jarak yang sama pada pilihan. Tata letak ordinal ini memang disengaja. Jika jarak seharusnya mencerminkan selisih numerik, petakan setiap nilai numerik ke posisi treknya sendiri dan pilih posisi terdekat; pembulatan indeks seragam akan menghasilkan interaksi yang berbeda.
Kontrol pembulatan yang andal menggunakan satu sistem koordinat dan satu pemetaan untuk masukan maupun gambar. Dengan keduanya selaras, indeks bilangan bulat menjaga setiap nilai tetap diskret, sementara pemisahan status pratinjau dan status yang diterapkan memperjelas perilaku saat dilepas.
Sumber
- 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