Untuk memilih bentuk di kanvas SwiftUI Mac, baca posisi penunjuk dalam ruang koordinat tampilan kanvas, lalu balik transformasi yang digunakan untuk menggambar. Dengan zoom dan pan, kurangi offset pan lalu bagi dengan zoom sebelum menguji bentuk Anda.
Tutorial ini membuat kanvas desktop kecil yang berisi dua persegi panjang sintetis. Anda akan menyorot persegi panjang di bawah penunjuk dan memilih persegi panjang saat gestur penunjuk berakhir, termasuk setelah mengubah zoom atau pan. Semua data gambar tetap berada di memori.
Ruang koordinat mana yang sebaiknya menerima peristiwa penunjuk?
Bedakan tiga ruang: sistem koordinat tempat bentuk Anda disimpan, sistem koordinat lokal tampilan kanvas, dan koordinat jendela apa pun yang disediakan AppKit. Sebuah titik hanya berguna jika Anda tahu ruang koordinat tempat titik itu berada.
onContinuousHover(coordinateSpace:perform:) milik SwiftUI menyediakan lokasi penunjuk dalam ruang koordinat yang diminta; nilai bawaannya adalah .local. Ini mengirimkan .active(location) saat penunjuk berada di dalam tampilan dan .ended saat penunjuk keluar. Pasang langsung pada kanvas agar ruang lokalnya sesuai dengan area pandang gambar. Dokumentasi Apple tentang hover berkelanjutan.
Ruang koordinat bernama berguna ketika sebuah tampilan leluhur sengaja memiliki wilayah interaksi. Penangan hover dan gestur harus meminta ruang bernama yang sama. Apple menetapkan .local, .global, dan .named(_:) sebagai ruang koordinat tata letak; memberi nama pada ruang tidak menentukan model gambar aplikasi. Dokumentasi Apple tentang ruang koordinat.
Perbedaan ini penting karena Canvas melakukan penggambaran mode langsung. Apple secara eksplisit mendokumentasikan bahwa elemen yang digambar secara individual, termasuk simbol, tidak menerima interaktivitas sendiri. Pendekatan yang didukung di sini adalah menangani interaksi pada kanvas yang memuatnya dan melakukan pengujian sentuh model sendiri. Dokumentasi Apple tentang Canvas.
Bagaimana cara membalik transformasi gambar?
Tentukan konvensi gambar yang eksplisit. Dalam contoh ini, koordinat model bertambah ke kanan dan ke bawah. Zoom menskalakan satuan model, sedangkan pan adalah offset yang diukur dalam titik lokal kanvas. Pemetaan maju dan inversnya adalah:
viewX = modelX * zoom + panX
viewY = modelY * zoom + panY
modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom
Persamaan ini mengikuti pemetaan yang dipilih tersebut. Untuk menggambarnya, geser konteks grafis sebesar pan, lalu skalakan sebesar zoom. Apple mendokumentasikan bahwa metode ini memperbarui transformasi konteks dan memengaruhi operasi gambar selanjutnya. Membalik urutannya akan mengubah pemetaan, jadi pasangkan invers dengan urutan gambar yang tepat. Dokumentasi translasi, dokumentasi penskalaan.
Untuk pemeriksaan ilustratif, gunakan zoom 2, pan (40, 30), dan posisi penunjuk lokal (180, 110). Mengurangi pan menghasilkan (140, 80); membaginya dengan zoom menghasilkan posisi model (70, 40). Menggambar titik model tersebut mengembalikannya ke (180, 110). Karena itu, persegi panjang yang bingkai modelnya memuat (70, 40) semestinya terdeteksi.
Simpan perhitungan ini dalam satu tipe bersama. Perender dan kode interaksi sebaiknya menggunakan pemetaan yang sama, bukan mempertahankan rumus offset yang terpisah. Contoh ini hanya menerima nilai zoom yang positif dan terbatas; nilai nol tidak dapat diinvers.
Buat contoh hover dan pemilihan yang lengkap
Gunakan proyek aplikasi SwiftUI macOS dengan target deployment macOS 14 atau lebih baru. Target tersebut mendukung overload CoordinateSpaceProtocol yang digunakan oleh modifier hover. Tambahkan berkas model berikut, lalu ganti deklarasi aplikasi yang dihasilkan proyek dengan berkas kedua. Pastikan hanya ada satu deklarasi @main dalam target. Ketersediaan dan tanda tangan API hover.
Definisikan geometri model dan pemetaannya
Buat CanvasGeometry.swift dengan isi lengkap berikut. Bingkai persegi panjang tetap berada dalam koordinat model selama interaksi.
import SwiftUI
struct CanvasMapping {
let zoom: CGFloat
let pan: CGSize
init(zoom: CGFloat, pan: CGSize) {
precondition(zoom.isFinite && zoom > 0)
precondition(pan.width.isFinite && pan.height.isFinite)
self.zoom = zoom
self.pan = pan
}
func modelPoint(from viewPoint: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - pan.width) / zoom,
y: (viewPoint.y - pan.height) / zoom
)
}
func apply(to context: inout GraphicsContext) {
context.translateBy(x: pan.width, y: pan.height)
context.scaleBy(x: zoom, y: zoom)
}
}
struct CanvasItem {
let id: Int
let frame: CGRect
}
Geometri dan pemetaan tersebut adalah struktur Swift. Semantik nilainya memudahkan Anda meneruskan nilai saat ini ke kode gambar dan pengujian sentuh tanpa memperkenalkan objek bersama lain yang dapat diubah. Panduan bahasa Swift mendokumentasikan struktur sebagai tipe nilai. Struktur dan Kelas.
Gambar dan berinteraksilah dengan area pandang yang sama
Buat PointerCanvasApp.swift dengan mengganti deklarasi aplikasi yang dihasilkan. Berkas lengkap ini menggunakan tipe dari CanvasGeometry.swift.
import SwiftUI
@main
struct PointerCanvasApp: App {
var body: some Scene {
WindowGroup {
PointerCanvasView()
}
}
}
struct PointerCanvasView: View {
private let items = [
CanvasItem(id: 1, frame: CGRect(
x: 40, y: 30, width: 120, height: 80)),
CanvasItem(id: 2, frame: CGRect(
x: 120, y: 70, width: 120, height: 80))
]
@State private var zoom: CGFloat = 2
@State private var pan = CGSize(width: 40, height: 30)
@State private var pointerInView: CGPoint?
@State private var selectedID: Int?
private var mapping: CanvasMapping {
CanvasMapping(zoom: zoom, pan: pan)
}
private func hit(at viewPoint: CGPoint) -> Int? {
let point = mapping.modelPoint(from: viewPoint)
return items.reversed().first {
$0.frame.contains(point)
}?.id
}
var body: some View {
let currentMapping = mapping
let hoveredID = pointerInView.flatMap { hit(at: $0) }
VStack(alignment: .leading, spacing: 12) {
HStack {
Button("Zoom out") {
zoom = max(0.5, zoom / 2)
}
Button("Zoom in") {
zoom = min(4, zoom * 2)
}
Button("Pan right") {
pan.width += 20
}
Button("Reset view") {
zoom = 2
pan = CGSize(width: 40, height: 30)
}
Text("Selected: \(selectedID.map { String($0) } ?? "none")")
}
Canvas { context, _ in
var drawing = context
currentMapping.apply(to: &drawing)
for item in items {
let path = Path(item.frame)
drawing.fill(
path,
with: .color(
item.id == hoveredID ? .orange : .blue
)
)
if item.id == selectedID {
drawing.stroke(
path,
with: .color(.black),
lineWidth: 2 / currentMapping.zoom
)
}
}
}
.background(Color.white)
.contentShape(Rectangle())
.onContinuousHover(coordinateSpace: .local) { phase in
switch phase {
case .active(let point):
pointerInView = point
case .ended:
pointerInView = nil
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { value in
selectedID = hit(at: value.location)
}
)
.clipped()
}
.padding(16)
.frame(minWidth: 640, minHeight: 420)
}
}
Kanvas menggunakan wilayah interaksi persegi panjang, termasuk ruang kosong di antara bentuk. contentShape(Rectangle()) mendefinisikan bentuk pengujian sentuh tampilan; pengujian frame.contains yang terpisah menentukan item sintetis mana yang berada di bawah penunjuk. Dokumentasi Apple tentang bentuk konten.
Pemilihan menggunakan DragGesture dengan jarak minimum nol dan membaca location dari peristiwa akhir. Perilaku yang ditetapkan adalah memilih persegi panjang di bawah posisi saat dilepas, meskipun penunjuk bergerak selama ditekan. Ini adalah pemilih berdasarkan posisi pelepasan, bukan pengenal klik yang ketat. Apple mendokumentasikan ambang jarak dan konfigurasi ruang koordinat gestur tersebut, serta menyebut location sebagai posisi peristiwa saat ini. DragGesture, DragGesture.Value.
Item digambar sesuai urutan dalam array dan diuji dengan urutan terbalik. Jadi, persegi panjang 2 akan menang di area yang saling bertumpang tindih, sesuai dengan susunan visual contoh ini. Mengklik ruang kosong akan menghapus pilihan. Bingkai bentuk dan isi array tidak pernah berubah.
Tampilan memiliki state penunjuk dan pilihan, sedangkan perender membacanya. Apple menyarankan agar state privat dideklarasikan di tampilan tertinggi yang membutuhkannya dan binding diteruskan jika tampilan turunan memerlukan akses tulis. Jika nantinya tampilan induk memiliki koleksi item atau pilihan, pindahkan kepemilikan ke sana dan teruskan nilai atau binding yang diperlukan ke tampilan ini. Dokumentasi Apple tentang state.
Bagaimana cara memeriksa pemetaan sebelum menambahkan lebih banyak gestur?
Mulailah dengan titik model yang diketahui dan hitung posisi lokal yang diharapkan. Titik pemeriksaan ini menggunakan zoom dan pan awal dari contoh:
| Titik model | Titik lokal kanvas yang diharapkan | Hasil yang diharapkan |
|---|---|---|
(70, 40) |
(180, 110) |
Persegi panjang 1 |
(140, 90) |
(320, 210) |
Persegi panjang 2, di area tumpang tindih |
(10, 10) |
(60, 50) |
Tidak ada persegi panjang |
Posisi lokal diukur dari kanvas itu sendiri; bilah alat dan bantalan luar tidak termasuk dalam ruang tersebut. Gerakkan penunjuk melintasi kedua persegi panjang, lepaskan di area tumpang tindih, lalu coba Zoom out dan Pan right. Sorotan seharusnya mengikuti geometri yang terlihat pada setiap pengaturan.
Perhitungan invers ilustratif dan 12 pemeriksaan bolak-balik maju/invers telah dijalankan menggunakan aritmetika Python. Kompilator Swift dan runtime UI macOS tidak tersedia di lingkungan riset, jadi aplikasi Swift telah diperiksa tetapi tidak dikompilasi atau diuji secara interaktif.
Jika pemeriksaan gagal, gunakan gejalanya untuk mempersempit penyebab:
- Offset konstan mengindikasikan bahwa penangan masukan berada pada tampilan yang berbeda atau pan tidak disertakan.
- Kesalahan yang bertambah seiring jarak mengindikasikan konversi skala yang hilang atau urutan transformasi yang tidak cocok.
- Hasil yang terbalik secara vertikal mengindikasikan pembalikan sumbu y tambahan atau titik AppKit yang belum dikonversi.
- Pemilihan yang keliru hanya di area tumpang tindih mengindikasikan urutan gambar dan pengujian sentuh yang tidak konsisten.
Simpan titik hover lokal mentah, seperti yang dilakukan contoh, dan turunkan titik model dari pemetaan saat ini. Ini mencegah penyimpanan posisi hover dalam ruang model yang dihitung dengan zoom sebelumnya. Pemilihan menggunakan lokasi dari peristiwa gestur itu sendiri, jadi tidak bergantung pada callback hover sebelumnya.
Apa yang berubah jika peristiwa berasal dari AppKit?
Jika tampilan AppKit kustom yang sudah ada menangani interaksi mouse, konversikan peristiwa di batas tersebut sebelum menerapkan invers gambar. NSEvent.locationInWindow biasanya menggunakan koordinat dasar jendela terkait. Apple menyatakan bahwa peristiwa dengan jendela nil dapat berisi koordinat layar, jadi pastikan terlebih dahulu bahwa peristiwa tersebut berasal dari jendela tampilan penerima. Dokumentasi Apple tentang lokasi peristiwa.
Di dalam NSView penerima tersebut, ekspresi convert(event.locationInWindow, from: nil) mengonversi titik jendela menjadi koordinat lokal tampilan. Apple mendokumentasikan nil sebagai sumber koordinat jendela untuk konversi ini. Baru setelah itu terapkan invers transformasi yang digunakan perender tampilan tersebut. Dokumentasi Apple tentang konversi titik.
Periksa orientasi di batas tampilan. Tampilan AppKit biasa memiliki titik asal di kiri bawah dengan sumbu y bertambah ke atas; tampilan yang isFlipped-nya menghasilkan true memiliki titik asal di kiri atas dengan sumbu y bertambah ke bawah. Permukaan gambar AppKit kustom dapat menggunakan konvensi terakhir agar sesuai dengan model ini. Biarkan konversi tampilan menangani perbedaan antarsistem koordinat tampilan, dan hindari menambahkan pembalikan lain setelahnya. Dokumentasi Apple tentang koordinat terbalik.
Saat mengekspos tampilan seperti itu melalui NSViewRepresentable, pertahankan penanganan peristiwa AppKit di tampilan yang direpresentasikan dan sampaikan hasil melalui callback atau koordinator yang eksplisit. Perbarui koneksi tersebut selama pembaruan dan bersihkan sumber daya yang dimiliki saat pembongkaran. SwiftUI mengendalikan tata letak tampilan yang direpresentasikan, jadi jangan mengatur frame atau bounds untuk mengompensasi kesalahan pemetaan. Dokumentasi Apple tentang siklus hidup representable.
Untuk contoh SwiftUI murni, pertahankan interaksi pada kanvas. Jika permukaan AppKit menggantikannya, jadikan permukaan tersebut pemilik wilayah interaksi. Konversi koordinat seharusnya menerjemahkan peristiwa yang diberikan oleh pemiliknya saat ini; konversi itu tidak seharusnya mengharuskan penerusan salinan kedua ke penangan pemilihan lain yang bersaing.
Bagaimana pemetaan sebaiknya dikembangkan untuk kanvas yang lebih besar?
Contoh ini sengaja mendukung zoom positif seragam dan translasi. Jika Anda menambahkan rotasi, penskalaan nonseragam, atau titik asal gambar yang berbeda, ganti invers sederhana dengan invers dari transformasi gambar lengkap. Tolak pemetaan yang tidak dapat diinvers sebelum interaksi, dan pertahankan pemeriksaan maju/invers dengan titik yang diketahui.
Untuk menyeret item di kemudian hari, catat item dan offset pegangan dalam ruang model saat gestur dimulai. Perbarui item yang sama selama gestur; tentukan secara eksplisit apakah pemetaan area pandang tetap atau berubah selama penyeretan. Mengulangi pengujian sentuh untuk memilih item baru pada setiap gerakan akan mengubah kepemilikan interaksi saat bentuk saling bertumpang tindih.
Gunakan satu ruang koordinat area pandang, satu pemetaan gambar, dan satu invers yang cocok. Setelah semuanya selaras, hover dan pemilihan dapat beroperasi pada geometri model yang stabil sementara zoom dan pan mengubah tampilannya.
Sumber
- Canvas — Apple
- onContinuousHover(coordinateSpace:perform:) — Apple
- CoordinateSpaceProtocol — Apple
- translateBy(x:y:) — Apple
- scaleBy(x:y:) — Apple
- Struktur dan Kelas — Swift.org
- contentShape(_:eoFill:) — Apple
- DragGesture — Apple
- DragGesture.Value — Apple
- State — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- isFlipped — Apple
- NSViewRepresentable — Apple