Buat diagram bagan bercabang di aplikasi Mac dengan menghitung pusat simpul sekali, menempatkan kartu menggunakan Layout SwiftUI khusus, dan menggambar konektor di belakangnya dengan Canvas. Tampilan gulir dua sumbu menjaga diagram tetap mudah dibaca saat jendela lebih kecil daripada kontennya.
Tutorial ini membuat diagram sintetis tujuh simpul dengan label netral seperti Node 1. Cabang memanjang ke kiri dari akar di sebelah kanan. Tujuan konkretnya adalah menampilkan kartu-kartu yang saling terhubung dan berjarak konsisten di jendela macOS yang dapat diubah ukurannya, dengan penempatan dan penggambaran yang menggunakan geometri yang sama.
Pilih struktur tata letak yang menjaga keselarasan kartu dan garis
Gunakan target deployment macOS 13 atau lebih baru untuk implementasi ini. Apple mendokumentasikan bahwa Layout tersedia mulai macOS 13. Dua metode wajibnya melaporkan ukuran kontainer dan menempatkan subview-nya. Karena itu, Layout cocok untuk menyusun kartu pada posisi yang telah dihitung. Layout
Jadikan kartu simpul sebagai tampilan SwiftUI biasa. Gunakan Canvas sebagai latar hanya untuk goresan konektor. Apple menjelaskan bahwa elemen individual yang digambar di dalam kanvas tidak memiliki interaktivitas atau aksesibilitas sendiri, termasuk tampilan yang disediakan sebagai simbol kanvas. Memisahkan kartu memudahkan penambahan kontrol biasa nantinya. Canvas
Implementasi ini terdiri dari tiga bagian: pohon yang mendeskripsikan cabang, perhitungan geometri yang menghasilkan pusat simpul dan sisi, serta tampilan yang menggabungkan kartu dengan gambar konektor. Kedua lapisan perenderan menerima nilai geometri yang sama. Dengan begitu, perubahan pada pohon tidak akan memperbarui kartu sementara tanpa sengaja membiarkan larik konektor terpisah yang dikelola secara independen.
Kebijakan ukuran sengaja dibuat tetap: ukuran kartu tidak berubah saat ukuran jendela berubah, dan area pandang di sekelilingnya dapat digulir. Apple mendokumentasikan bahwa ScrollView mendukung pengguliran horizontal, vertikal, atau keduanya; ScrollView tidak menyediakan pembesaran. ScrollView
Hitung posisi cabang berdasarkan jarak daun
Mulailah dari daun, yaitu simpul yang tidak memiliki anak. Tetapkan slot vertikal berurutan untuk setiap daun. Lalu tempatkan setiap induk tepat di tengah antara anak pertama dan anak terakhirnya. Posisi horizontal bergantung pada kedalaman: akar menempati kolom paling kanan, dan setiap tingkat kedalaman tambahan bergerak satu kolom ke kiri.
Untuk contoh ini, gunakan kartu berukuran 132 kali 44 poin, jarak horizontal 64 poin, dan jarak vertikal 28 poin. Nilai ini adalah masukan desain ilustratif. Dengan tiga kolom dan empat daun, ukuran konten yang dihasilkan adalah 524 kali 260 poin sebelum padding luar.
| Besaran | Perhitungan | Hasil |
|---|---|---|
| Langkah kolom | Lebar kartu + jarak horizontal | 196 poin |
| Langkah daun | Tinggi kartu + jarak vertikal | 72 poin |
| Lebar konten | 3 × 132 + 2 × 64 | 524 poin |
| Tinggi konten | 4 × 44 + 3 × 28 | 260 poin |
Dengan demikian, koordinat vertikal pusat keempat daun adalah 22, 94, 166, dan 238. Induk langsungnya berada pada 58 dan 202, sedangkan akar berada pada 130. Koordinat ini berguna untuk pemeriksaan: induk harus berada di tengah antara pusat anak-anak terluarnya, dan setiap kartu harus muat di dalam persegi panjang konten yang dilaporkan.
Algoritma di bawah ini juga menerima kedalaman cabang yang tidak sama. Daun yang dangkal tetap berada di kolom kedalamannya, alih-alih otomatis berpindah ke kolom paling kiri. Itu adalah aturan yang disengaja dalam contoh ini. Tentukan apakah penyelarasan berdasarkan kedalaman atau daun lebih cocok untuk diagram Anda sebelum mengubah rumus koordinat.
Bangun contoh macOS lengkap
Gunakan target aplikasi macOS SwiftUI di Xcode dengan target deployment macOS 13 atau lebih baru. Ganti berkas entri aplikasi yang dibuat oleh Xcode dengan tiga blok kode berikut, secara berurutan, dalam satu berkas Swift. Ketiganya membentuk program lengkap. Hapus deklarasi aplikasi lama jika berada di berkas lain: Apple mewajibkan tepat satu titik masuk aplikasi. App
Definisikan pohon dan geometrinya
Setiap simpul memiliki pengenal bilangan bulat dan larik anak. Pembuat geometri mengunjungi anak sebelum menghitung pusat induknya, sekaligus mengumpulkan penempatan simpul dan sisi. Pengenal yang berulang akan memicu kegagalan prasyarat alih-alih menghasilkan kumpulan tampilan yang ambigu.
import SwiftUI
struct BranchNode {
let id: Int
let children: [BranchNode]
init(_ id: Int, _ children: [BranchNode] = []) {
self.id = id
self.children = children
}
var depth: Int {
(children.map { $0.depth + 1 }.max()) ?? 0
}
}
struct DiagramNode: Identifiable {
let id: Int
let center: CGPoint
}
struct DiagramEdge {
let child: CGPoint
let parent: CGPoint
}
struct DiagramGeometry {
static let cardSize = CGSize(width: 132, height: 44)
static let horizontalGap: CGFloat = 64
static let verticalGap: CGFloat = 28
let nodes: [DiagramNode]
let edges: [DiagramEdge]
let size: CGSize
static func make(root: BranchNode) -> DiagramGeometry {
let maximumDepth = root.depth
var nextLeaf = 0
var nodes: [DiagramNode] = []
var edges: [DiagramEdge] = []
var identifiers = Set<Int>()
func visit(_ node: BranchNode, depth: Int) -> CGPoint {
precondition(
identifiers.insert(node.id).inserted,
"Every node must have a unique identifier."
)
let x = cardSize.width / 2
+ CGFloat(maximumDepth - depth)
* (cardSize.width + horizontalGap)
let childCenters = node.children.map {
visit($0, depth: depth + 1)
}
let y: CGFloat
if let first = childCenters.first,
let last = childCenters.last {
y = (first.y + last.y) / 2
} else {
y = cardSize.height / 2
+ CGFloat(nextLeaf)
* (cardSize.height + verticalGap)
nextLeaf += 1
}
let center = CGPoint(x: x, y: y)
nodes.append(DiagramNode(id: node.id, center: center))
for child in childCenters {
edges.append(DiagramEdge(child: child, parent: center))
}
return center
}
_ = visit(root, depth: 0)
let size = CGSize(
width: CGFloat(maximumDepth + 1) * cardSize.width
+ CGFloat(maximumDepth) * horizontalGap,
height: CGFloat(nextLeaf) * cardSize.height
+ CGFloat(max(0, nextLeaf - 1)) * verticalGap
)
return DiagramGeometry(nodes: nodes, edges: edges, size: size)
}
}
Model ini selalu memiliki akar, sehingga bahkan pohon tanpa anak pun menghasilkan satu kartu dengan ukuran konten bukan nol. Program ini tidak memiliki kasus khusus untuk diagram kosong. Jika aplikasi mengizinkan ketiadaan akar, tangani kondisi itu secara eksplisit sebelum membuat geometri ini.
Tempatkan kartu menggunakan nilai tata letak khusus
Teruskan pusat setiap kartu melalui LayoutValueKey. Pola yang didokumentasikan Apple adalah menetapkan nilai dengan layoutValue(key:value:), lalu membacanya dari proksi subview menggunakan kunci tersebut. Cara ini mengaitkan posisi dengan kartunya, bukan mengandalkan indeks larik yang sejajar. LayoutValueKey
struct DiagramCenterKey: LayoutValueKey {
static let defaultValue: CGPoint = .zero
}
struct BranchLayout: Layout {
let contentSize: CGSize
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
contentSize
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let cardProposal = ProposedViewSize(
width: DiagramGeometry.cardSize.width,
height: DiagramGeometry.cardSize.height
)
for subview in subviews {
let center = subview[DiagramCenterKey.self]
subview.place(
at: CGPoint(
x: bounds.minX + center.x,
y: bounds.minY + center.y
),
anchor: .center,
proposal: cardProposal
)
}
}
}
Tata letak mengembalikan ukuran terbatas yang sama untuk setiap proposal karena kontennya tidak fleksibel. Apple secara eksplisit menunjukkan bahwa tata letak yang tidak fleksibel dapat mengabaikan proposal. SwiftUI dapat memanggil metode pengukuran beberapa kali dengan proposal berbeda selama satu tahap tata letak, jadi metode ini cukup mengembalikan geometri; metode ini tidak memperbarui model. sizeThatFits(proposal:subviews:cache:)
Penempatan menambahkan bounds.minX dan bounds.minY ke setiap pusat lokal. Batas yang diberikan dinyatakan dalam sistem koordinat induk, sehingga mengasumsikan titik asalnya nol akan merusak penempatan saat kontainer berpindah. Apple juga mewajibkan perhitungan ukuran dan penempatan menggunakan jarak serta dimensi yang selaras. placeSubviews(in:proposal:subviews:cache:)
Penggunaan .center membuat titik yang diberikan mewakili bagian tengah setiap kartu. Setiap proksi ditempatkan tepat satu kali, di dalam metode penempatan, sesuai persyaratan API penempatan subview. Bingkai kartu eksplisit di blok berikutnya memastikan tampilan kartu sesuai dengan dimensi geometri. place(at:anchor:proposal:)
Gambar konektor dan tampilkan jendela
Setiap konektor dimulai di tepi kanan kartu anak dan berakhir di tepi kiri kartu induk. Dua titik perantara membentuk jalur bersudut siku-siku melalui tengah celah. Path.addLines(_:) membuat segmen garis lurus yang saling terhubung dari larik titik. addLines(_:)
struct BranchDiagram: View {
let geometry: DiagramGeometry
var body: some View {
BranchLayout(contentSize: geometry.size) {
ForEach(geometry.nodes) { node in
Text("Node \(node.id)")
.font(.body)
.frame(
width: DiagramGeometry.cardSize.width,
height: DiagramGeometry.cardSize.height
)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.12))
)
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary, lineWidth: 1)
}
.layoutValue(
key: DiagramCenterKey.self,
value: node.center
)
}
}
.background {
Canvas { context, _ in
var path = Path()
for edge in geometry.edges {
let start = CGPoint(
x: edge.child.x
+ DiagramGeometry.cardSize.width / 2,
y: edge.child.y
)
let end = CGPoint(
x: edge.parent.x
- DiagramGeometry.cardSize.width / 2,
y: edge.parent.y
)
let middleX = (start.x + end.x) / 2
path.addLines([
start,
CGPoint(x: middleX, y: start.y),
CGPoint(x: middleX, y: end.y),
end
])
}
context.stroke(
path,
with: .color(.secondary),
lineWidth: 1.5
)
}
}
}
}
@main
struct BranchDiagramApp: App {
let geometry = DiagramGeometry.make(
root: BranchNode(1, [
BranchNode(2, [BranchNode(4), BranchNode(5)]),
BranchNode(3, [BranchNode(6), BranchNode(7)])
])
)
var body: some Scene {
WindowGroup {
ScrollView([.horizontal, .vertical]) {
BranchDiagram(geometry: geometry)
.padding(24)
}
.frame(minWidth: 360, minHeight: 240)
}
}
}
ForEach menerima nilai simpul yang dapat diidentifikasi, sesuai persyaratan Apple yang mengharuskan elemen dapat diidentifikasi atau argumen pengenal eksplisit. Jika nantinya pohon dapat diedit, pertahankan pengenal pada simpul logisnya. ForEach
Kanvas dipasang langsung pada latar tata letak, sehingga koordinat lokalnya cocok dengan geometri yang digunakan untuk kartu. Padding luar diterapkan setelah diagram dirakit dan menggeser kedua lapisan bersama-sama. Bagian konektor antarsaudara yang berimpit membentuk persimpangan menyerupai kurung.
WindowGroup menyediakan hierarki tampilan jendela dan perilaku standar pengelolaan jendela macOS. Contoh ini menyimpan geometri sintetis yang tidak dapat diubah dalam struktur aplikasinya; contoh ini tidak memasang pengatur waktu atau callback siklus hidup untuk mengubah posisi tampilan. WindowGroup
Verifikasi diagram dan diagnosis masalah umum
Aritmetika untuk contoh ini diperiksa dengan perhitungan Python deterministik sederhana: tujuh pusat, enam sisi, persegi panjang konten berukuran 524 kali 260 poin, dan semua kartu berada di dalam persegi panjang tersebut. Program SwiftUI sudah diperiksa, tetapi tidak dapat dikompilasi di lingkungan riset karena kompiler Swift tidak tersedia.
Saat membangun aplikasi di Xcode, periksa hasil yang dapat diamati berikut:
- Empat kartu muncul di kolom paling kiri, dua di kolom tengah, dan satu di sebelah kanan.
- Setiap konektor menyentuh titik tengah horizontal pada tepi kartu yang bersangkutan.
- Setiap induk berada secara vertikal di antara anak-anak terluarnya.
- Mengubah ukuran jendela mempertahankan dimensi kartu dan menampilkan pengguliran bila diperlukan.
- Mengganti akar contoh dengan
BranchNode(1)menampilkan satu kartu tanpa konektor.
Jika kartu dan garis bergeser, periksa penempatan modifier terlebih dahulu. Padding atau offset yang hanya diterapkan pada kartu menambahkan penyesuaian koordinat kedua yang tidak digunakan oleh geometri konektor. Pertahankan jarak untuk seluruh diagram di luar BranchDiagram, seperti yang ditunjukkan.
Jika kartu muncul tanpa diduga di titik asal tata letak, periksa apakah tampilan akhirnya memiliki nilai DiagramCenterKey. Nilai bawaan kunci tersebut adalah .zero; karena itu, penetapan yang hilang menempatkan pusat kartu di titik asal. Jika program berhenti pada prasyarat pengenal, berikan pengenal yang berbeda kepada setiap simpul sebelum dirender.
Dimensi kartu tetap cocok untuk label sintetis pendek dalam contoh ini. Untuk teks yang lebih panjang atau ukuran font yang lebih besar, tambahkan tahap pengukuran: minta proksi subview memberikan ukurannya, hitung lebar kolom dan jarak antarsubpohon berdasarkan hasil pengukuran tersebut, lalu gunakan hasil yang sama untuk titik ujung konektor. Proposal ukuran saja tidak memaksa subview menerima ukuran tersebut. place(at:anchor:proposal:)
Algoritma ini menjelaskan sebuah pohon: setiap anak yang ditampilkan hanya memiliki satu induk. Algoritma ini tidak mengatur rute tautan silang sembarang atau merepresentasikan satu simpul yang digunakan oleh beberapa induk. Kebutuhan tersebut memerlukan model geometri berbeda dan kebijakan perutean yang eksplisit. Demikian pula, animasi memerlukan geometri konektor bergerak selaras dengan posisi kartu; contoh ini hanya menyediakan penempatan statis.
Jadikan satu perhitungan geometri sebagai sumber ukuran konten, pusat kartu, dan titik ujung sisi. Dengan begitu, Anda memiliki satu tempat konkret untuk menyesuaikan jarak, memeriksa koordinat, dan mengembangkan diagram sambil menjaga keselarasan antara tata letak dan penggambaran SwiftUI.
Sumber
- Layout — Apple
- Canvas — Apple
- ScrollView — Apple
- App — Apple
- LayoutValueKey — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple
- place(at:anchor:proposal:) — Apple
- addLines(_:) — Apple
- ForEach — Apple
- WindowGroup — Apple