أنشئ مخططًا متشعبًا للأدوار في تطبيق Mac بحساب مراكز العقد مرة واحدة، ووضع البطاقات باستخدام Layout مخصص في SwiftUI، ورسم الوصلات خلفها باستخدام Canvas. تحافظ مساحة تمرير على محورين على وضوح المخطط عندما تكون النافذة أصغر من محتواها.

ينشئ هذا البرنامج التعليمي مخططًا اصطناعيًا من سبع عقد بتسميات محايدة مثل Node 1. تمتد الفروع نحو اليسار انطلاقًا من جذر على اليمين. تتمثل المهمة المحددة في عرض بطاقات متصلة ومتباعدة بانتظام في نافذة macOS قابلة لتغيير الحجم، مع اعتماد التموضع والرسم على الهندسة نفسها.

اختر بنية تخطيط تحافظ على محاذاة البطاقات والخطوط

استخدم هدف نشر macOS بإصدار 13 أو أحدث لهذا التنفيذ. توضح Apple أن Layout متاح بدءًا من macOS 13. وتحدد طريقتاه المطلوبتان حجم الحاوية وتضعان عروضها الفرعية. وهذا يجعله مناسبًا لترتيب البطاقات في مواضع محسوبة. Layout

أبقِ بطاقات العقد عروض SwiftUI عادية. استخدم Canvas في الخلفية لرسم الوصلات فقط. توضح Apple أن العناصر الفردية المرسومة داخل لوحة لا تتمتع بتفاعل أو إمكانية وصول خاصة بها، بما في ذلك العروض المقدمة كرموز للوحة. يحافظ فصل البطاقات على مسار واضح لإضافة عناصر تحكم عادية لاحقًا. Canvas

يتكون التنفيذ من ثلاثة أجزاء: شجرة تصف الفروع، وحساب هندسي ينتج مراكز العقد والحواف، وعرض يجمع البطاقات مع رسم الوصلات. تتلقى طبقتا العرض قيمة الهندسة نفسها. لذلك لا يمكن أن يؤدي تغيير الشجرة إلى تحديث البطاقات مع ترك مصفوفة وصلات مستقلة لم تتغير عن طريق الخطأ.

سياسة التحجيم ثابتة عمدًا: تحتفظ البطاقات بالحجم نفسه عند تغيير النافذة، وتكون مساحة العرض المحيطة قابلة للتمرير. توضح Apple أن ScrollView يدعم التمرير أفقيًا أو عموديًا أو على المحورين؛ لكنه لا يوفر التكبير. ScrollView

احسب مواضع الفروع انطلاقًا من تباعد الأوراق

ابدأ بالأوراق، وهي العقد التي ليس لها أبناء. خصص لكل ورقة خانة رأسية متتابعة. ثم ضع كل والد في المنتصف بين أول أبنائه وآخرهم. يعتمد الموضع الأفقي على العمق: يحتل الجذر العمود الأيمن، وينتقل كل عمق إضافي عمودًا واحدًا إلى اليسار.

استخدم في هذا المثال بطاقات أبعادها 132 × 44 نقطة، وفجوة أفقية قدرها 64 نقطة، وفجوة رأسية قدرها 28 نقطة. هذه مدخلات تصميم توضيحية. مع ثلاثة أعمدة وأربع أوراق، يصبح حجم المحتوى 524 × 260 نقطة قبل إضافة الهوامش الخارجية.

الكمية الحساب النتيجة
خطوة العمود عرض البطاقة + الفجوة الأفقية 196 نقطة
خطوة الورقة ارتفاع البطاقة + الفجوة الرأسية 72 نقطة
عرض المحتوى 3 × 132 + 2 × 64 524 نقطة
ارتفاع المحتوى 4 × 44 + 3 × 28 260 نقطة

يمنح هذا مراكز الأوراق الأربع إحداثيات رأسية تبلغ 22 و94 و166 و238. وتقع آباؤها المباشرون عند 58 و202، بينما يقع الجذر عند 130. توفر هذه الإحداثيات وسيلة تشخيص مفيدة: ينبغي أن يكون الوالد في المنتصف بين مركزي أبعد أبنائه، وأن تتسع مستطيلة المحتوى المعلنة لكل بطاقة.

تقبل الخوارزمية أدناه أيضًا فروعًا متفاوتة العمق. تبقى الورقة الضحلة في عمود عمقها بدلًا من الانتقال تلقائيًا إلى أقصى عمود يسارًا. وهذه قاعدة مقصودة في هذا المثال. قرر ما إذا كان محاذاة العمق أو محاذاة الأوراق أنسب لمخططك قبل تغيير صيغة الإحداثيات.

أنشئ مثال macOS كاملًا

استخدم هدف تطبيق SwiftUI لنظام macOS في Xcode، وحدد هدف نشر macOS 13 أو أحدث. استبدل ملف إدخال التطبيق الذي أنشأه Xcode تلقائيًا بكتل الشيفرة الثلاث التالية، بالترتيب، في ملف Swift واحد. وهي تشكل البرنامج كاملًا. أزل تعريف التطبيق القديم إذا كان موجودًا في ملف آخر: تشترط Apple وجود نقطة إدخال واحدة للتطبيق فقط. App

عرّف الشجرة وهندستها

تحتوي كل عقدة على معرّف عدد صحيح ومصفوفة من الأبناء. يزور منشئ الهندسة الأبناء قبل حساب مركز الوالد، ويجمع مواضع العقد والحواف. يؤدي تكرار معرّف إلى فشل شرط مسبق بدلًا من إنشاء مجموعة عروض ملتبسة.

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)
    }
}

يحتوي النموذج دائمًا على جذر، لذا فإن الشجرة التي لا تضم أبناءً تنتج بطاقة واحدة بحجم محتوى غير صفري. لا توجد في هذا البرنامج حالة منفصلة لمخطط فارغ. إذا كان تطبيقك يسمح بعدم وجود جذر، فعالج هذه الحالة صراحةً قبل إنشاء هذه الهندسة.

ضع البطاقات باستخدام قيمة تخطيط مخصصة

مرّر مركز كل بطاقة عبر LayoutValueKey. النمط الذي توثقه Apple هو تعيين قيمة باستخدام layoutValue(key:value:)، ثم قراءتها من وكيل العرض الفرعي باستخدام المفتاح. يربط هذا الموضع ببطاقته بدلًا من الاعتماد على فهارس متوازية في المصفوفات. 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
            )
        }
    }
}

يعيد التخطيط الحجم المحدود نفسه لكل مقترح، لأن محتواه غير مرن. وتوضح Apple صراحةً أن التخطيط غير المرن يمكنه تجاهل المقترح. قد تستدعي SwiftUI طريقة التحجيم عدة مرات بمقترحات مختلفة خلال تمريرة تخطيط واحدة، لذا تعيد هذه الطريقة الهندسة فحسب، ولا تجري تحديثات للنموذج. sizeThatFits(proposal:subviews:cache:)

يضيف التموضع bounds.minX وbounds.minY إلى كل مركز محلي. تُعبّر الحدود المقدمة عن الإحداثيات في مساحة الأب، لذا فإن افتراض أن نقطة الأصل تساوي صفرًا سيؤدي إلى خطأ في التموضع عندما تتحرك الحاوية. وتشترط Apple أيضًا أن تتفق حسابات التحجيم والتموضع بشأن التباعد والأبعاد. placeSubviews(in:proposal:subviews:cache:)

باستخدام .center تمثل النقطة المقدمة منتصف كل بطاقة. ويوضع كل وكيل مرة واحدة بالضبط داخل طريقة التموضع، كما تشترط واجهة برمجة تطبيقات تموضع العروض الفرعية. تضمن إطارات البطاقات الصريحة في الكتلة التالية أن تطابق البطاقات المعروضة أبعاد الهندسة. place(at:anchor:proposal:)

ارسم الوصلات واعرض النافذة

تبدأ كل وصلة عند الحافة اليمنى لبطاقة الابن وتنتهي عند الحافة اليسرى لبطاقة الوالد. تنشئ نقطتان وسيطتان مسارًا بزاوية قائمة عبر منتصف الفجوة. تنشئ Path.addLines(_:) مقاطع مستقيمة متصلة من مصفوفة نقاط. 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 قيم عقد قابلة للتعريف، بما يتوافق مع شرط Apple أن تكون العناصر قابلة للتعريف أو أن تُمرر وسيطة معرّف صريحة. أبقِ المعرّفات مرتبطة بالعقد المنطقية إذا جعلت الشجرة قابلة للتحرير لاحقًا. ForEach

ترتبط اللوحة مباشرة بخلفية التخطيط، لذا تطابق إحداثياتها المحلية الهندسة المستخدمة للبطاقات. تُطبق الهوامش الخارجية بعد تجميع المخطط، فتحرك الطبقتين معًا. وتتداخل الأجزاء المشتركة لوصلات الأشقاء لتشكل نقطة التقاء على هيئة قوس.

توفر WindowGroup التسلسل الهرمي لعروض النافذة وسلوك إدارة النوافذ المعتاد في macOS. يحتفظ المثال بهندسة اصطناعية غير قابلة للتغيير ضمن بنية التطبيق؛ ولا يثبت مؤقتات أو استدعاءات لدورة الحياة لإعادة تموضع العروض. WindowGroup

تحقّق من المخطط وشخّص المشكلات الشائعة

جرى التحقق من حسابات هذا المثال باستخدام عملية حسابية صغيرة وحتمية بلغة Python: سبعة مراكز، وست حواف، ومستطيل محتوى أبعاده 524 × 260 نقطة، وكل بطاقة داخل ذلك المستطيل. جرت مراجعة برنامج SwiftUI، لكن تعذّر تجميعه في بيئة البحث لعدم توفر مترجم Swift.

عند إنشاء التطبيق في Xcode، تحقق من النتائج الظاهرة التالية:

  1. تظهر أربع بطاقات في العمود الأيسر، وبطاقتان في الوسط، وواحدة على اليمين.
  2. تلامس كل وصلة منتصف الحافة الأفقية للبطاقة المعنية.
  3. تقع كل بطاقة والد رأسيًا بين أبعد أبنائها.
  4. يحافظ تغيير حجم النافذة على أبعاد البطاقات ويظهر التمرير عند الحاجة.
  5. يؤدي استبدال جذر العينة بـ BranchNode(1) إلى عرض بطاقة واحدة بلا وصلات.

إذا كانت البطاقات والخطوط غير محاذية، فتحقق أولًا من مواضع المعدّلات. فالهوامش أو الإزاحات المطبقة على البطاقات وحدها تضيف تعديلًا ثانيًا للإحداثيات لا تشاركه هندسة الوصلات. أبقِ التباعد على مستوى المخطط خارج BranchDiagram كما هو موضح.

إذا ظهرت بطاقة على نحو غير متوقع عند نقطة أصل التخطيط، فتحقق من أن قيمة DiagramCenterKey موجودة في العرض النهائي. القيمة الافتراضية للمفتاح هي .zero؛ لذا يؤدي غياب التعيين إلى وضع مركز البطاقة عند نقطة الأصل. وإذا توقف البرنامج عند شرط المعرّف المسبق، فامنح كل عقدة معرّفًا فريدًا قبل العرض.

تناسب أبعاد البطاقات الثابتة التسميات الاصطناعية القصيرة في هذا المثال. وللنصوص الأطول أو الخطوط الأكبر، أضف مرحلة قياس: اطلب أحجام العروض الفرعية من الوكلاء، واحسب عروض الأعمدة والمسافات بين الفروع انطلاقًا من تلك القياسات، واستخدم النتائج نفسها لنقاط نهاية الوصلات. فمقترح الحجم وحده لا يجبر العرض الفرعي على قبول ذلك الحجم. place(at:anchor:proposal:)

تصف هذه الخوارزمية شجرةً ينتمي فيها كل ابن معروض إلى والد واحد. وهي لا توجّه الروابط المتقاطعة الاعتباطية ولا تمثل عقدة مشتركة تحت عدة آباء. تتطلب هذه الحالات نموذج هندسة مختلفًا وسياسة توجيه صريحة. كذلك، تتطلب الحركة مزامنة حركة هندسة الوصلات مع مواضع البطاقات؛ فهذا المثال يوفر تموضعًا ثابتًا.

اجعل حسابًا هندسيًا واحدًا مسؤولًا عن حجم المحتوى ومراكز البطاقات ونقاط نهاية الحواف. يمنحك ذلك موضعًا محددًا لتعديل التباعد وفحص الإحداثيات وتوسيع المخطط مع الحفاظ على التوافق بين تخطيط SwiftUI والرسم.

المصادر