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

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

أي حاوية في SwiftUI تدعم هذا الترتيب؟

تدعم Grid من Apple خلية تمتد على عدة أعمدة باستخدام gridCellColumns(_:) داخل GridRow. لكن عليك توفير الصفوف بنفسك. أما العرض الموضوع مباشرة داخل Grid وخارج GridRow فيمتد على الشبكة بأكملها. ولا يرتب أي من الخيارين مجموعة مسطحة تلقائيًا في صفوف يتغير عددها حسب العرض. راجع وثائق Grid ووثائق gridCellColumns(_:) من Apple.

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

اختر الحاوية وفق السلوك الذي تحتاج إليه:

المتطلب النهج المناسب
صفوف محددة مع خلية تمتد على عمودين Grid وGridRow وgridCellColumns(2)
صفوف تُملأ تلقائيًا بخلايا عادية LazyVGrid
صفوف تُملأ تلقائيًا مع خلية أولى تمتد على خانتين Layout مخصص ينفذ قاعدة الترتيب هذه

في الحالة الأخيرة، واجهة التوسعة التي تدعمها Apple هي البروتوكول Layout. وتتمثل مهمتاه الأساسيتان في تحديد حجم الحاوية ووضع عناصرها. التنفيذ أدناه خوارزمية ترتيب أصلية تستند إلى هاتين المهمتين الموثقتين. Layout — Apple

حدّد قواعد التحجيم قبل كتابة التخطيط

يستخدم هذا المثال حدًا أدنى لعرض العمود العادي قدره 96 نقطة وفاصلًا قدره 8 نقاط. تتسع الأعمدة بالتساوي للاستفادة من العرض المتاح. يشغل العنصر الأول خانتين، بما في ذلك الفاصل بينهما؛ أما كل عنصر آخر فيشغل خانة واحدة. تُملأ الصفوف بالعناصر حسب ترتيبها الحالي، ويكون ارتفاع كل صف مساويًا لارتفاع أطول عنصر فيه.

للعرض المتاح W، والحد الأدنى لعرض العمود m، والفاصل g، يكون عدد الأعمدة max(1, floor((W + g) / (m + g))). ويكون عرض العمود العادي الفعلي (W - (columns - 1) × g) / columns. لذا تحصل البلاطة ذات الخانتين على عرض 2 × columnWidth + g نقطة.

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

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

نفّذ التخطيط في تطبيق macOS

أنشئ تطبيق macOS باستخدام SwiftUI في Xcode، واضبط هدف النشر على macOS 13 أو أحدث، إذ يتوفر Layout بدءًا منه. أضف ملف Swift باسم WideFirstGrid.swift وضع فيه نوع التخطيط الكامل التالي. Layout — Apple

import SwiftUI

struct WideFirstGrid: Layout {
    let minimumColumnWidth: CGFloat = 96
    let gap: CGFloat = 8

    struct Plan {
        var size: CGSize
        var frames: [CGRect]
    }

    func makePlan(width proposedWidth: CGFloat?, subviews: Subviews) -> Plan {
        guard !subviews.isEmpty else {
            return Plan(size: .zero, frames: [])
        }

        let totalSlots = subviews.count + (subviews.count > 1 ? 1 : 0)
        let idealColumns = min(4, totalSlots)
        let idealWidth = CGFloat(idealColumns) * minimumColumnWidth
            + CGFloat(idealColumns - 1) * gap
        let maximumWidth = CGFloat(totalSlots) * minimumColumnWidth
            + CGFloat(totalSlots - 1) * gap

        let width: CGFloat
        if let proposedWidth {
            if proposedWidth.isFinite {
                width = max(minimumColumnWidth, proposedWidth)
            } else {
                width = maximumWidth
            }
        } else {
            width = idealWidth
        }

        let columns = max(1, Int(floor(
            (width + gap) / (minimumColumnWidth + gap)
        )))
        let columnWidth = (width - CGFloat(columns - 1) * gap)
            / CGFloat(columns)

        var frames: [CGRect] = []
        var column = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0

        for (index, subview) in subviews.enumerated() {
            let span = index == 0 ? min(2, columns) : 1
            if column + span > columns {
                y += rowHeight + gap
                column = 0
                rowHeight = 0
            }

            let cellWidth = CGFloat(span) * columnWidth
                + CGFloat(span - 1) * gap
            let measured = subview.sizeThatFits(
                ProposedViewSize(width: cellWidth, height: nil)
            )
            frames.append(CGRect(
                x: CGFloat(column) * (columnWidth + gap),
                y: y,
                width: cellWidth,
                height: measured.height
            ))
            rowHeight = max(rowHeight, measured.height)
            column += span
        }

        return Plan(
            size: CGSize(width: width, height: y + rowHeight),
            frames: frames
        )
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        makePlan(width: proposal.width, subviews: subviews).size
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let plan = makePlan(width: bounds.width, subviews: subviews)
        for (subview, frame) in zip(subviews, plan.frames) {
            subview.place(
                at: CGPoint(
                    x: bounds.minX + frame.minX,
                    y: bounds.minY + frame.minY
                ),
                anchor: .topLeading,
                proposal: ProposedViewSize(width: frame.width, height: nil)
            )
        }
    }
}

تُستخدم دالة التخطيط نفسها في القياس والتموضع. يُقاس كل عنصر بالعرض المخصص له وبارتفاع غير محدد، ثم يوضع باستخدام اقتراح الحجم نفسه. وهذا يحافظ على اتساق التفاف النص وحسابات ارتفاع الصفوف. وتشترط Apple تحديدًا توافق الحسابات بين التحجيم والتموضع. placeSubviews(in:proposal:subviews:cache:) — Apple

يضيف التموضع bounds.minX وbounds.minY إلى كل موضع محسوب. وقد لا تبدأ حدود المساحة المخصصة من العنصر الأب عند الصفر. ويُستدعى التموضع مرة واحدة لكل عنصر، ولا تحدث هذه الاستدعاءات إلا داخل placeSubviews، وفق ما يقتضيه عقد تموضع LayoutSubview.

أضف البلاطات الاصطناعية وإجراءات الأزرار

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

import SwiftUI

@main
struct WideFirstGridDemoApp: App {
    var body: some Scene {
        WindowGroup {
            PaletteView()
                .frame(minWidth: 240, minHeight: 260)
        }
    }
}

struct PaletteView: View {
    @State private var selectedNumber: Int? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Tile palette")
                .font(.title2)

            Text(selectedNumber.map { "Selected tile: \($0)" }
                 ?? "Choose a numbered tile")

            ScrollView {
                WideFirstGrid {
                    Text("Choose a tile from this sample collection")
                        .font(.headline)
                        .padding(12)
                        .frame(
                            maxWidth: .infinity,
                            minHeight: 64,
                            alignment: .leading
                        )
                        .background(
                            Color.blue.opacity(0.12),
                            in: RoundedRectangle(cornerRadius: 10)
                        )

                    ForEach(1...11, id: \.self) { number in
                        Button {
                            selectedNumber = number
                        } label: {
                            Text("Tile \(number)")
                                .padding(12)
                                .frame(maxWidth: .infinity, minHeight: 64)
                                .background(
                                    Color.gray.opacity(0.12),
                                    in: RoundedRectangle(cornerRadius: 10)
                                )
                        }
                        .buttonStyle(.plain)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .padding(16)
    }
}

تمنح المجموعة الرقمية الثابتة كل زر معرّفًا فريدًا ومستقرًا عبر id: \.self. يحسب التخطيط الأبعاد الهندسية عبر وكلاء العناصر الفرعية؛ أما الاختيار فيبقى ضمن حالة العرض ولا يتغير إلا في إجراء الزر. توضح Apple أن ForEach يتطلب عناصر قابلة للتعريف أو معرّفًا صريحًا. ForEach — Apple

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

تحقّق من تغيير الحجم والعروض الضيقة ودورة حياة التخطيط

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

عرض محتوى الشبكة الأعمدة عرض الخلية الأولى إجمالي الصفوف
96 نقطة 1 96 نقطة 12
200 نقطة 2 200 نقطة 7
304 نقاط 3 200 نقطة 5
408 نقاط 4 200 نقطة 4

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

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

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

افهم الحدود قبل توسيع التنفيذ

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

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

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

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

المصادر