لجعل NSTextView ملتفًا يلائم إطار SwiftUI، مثّل NSScrollView، واقبل الأبعاد المقترحة المحدودة عبر sizeThatFits، ودع مستند النص يتمدد عموديًا بداخله. اضبط حاوية النص لتتبع عرض المحرر كي تلتف الأسطر الطويلة.

يبني هذا البرنامج التعليمي محرر نص عادي لتطبيق Mac خاص بك، بمساحة مرئية محدودة. تؤدي إضافة النص إلى تمرير عمودي من دون تكبير تخطيط SwiftUI. يستهدف المثال الكامل macOS 13 أو أحدث، حيث تتيح Apple دالة استدعاء تغيير الحجم للتمثيل. وثائق Apple لـsizeThatFits

لماذا قد يبدو أن NSTextView يتجاهل إطار SwiftUI؟

هناك ثلاثة أحجام يجب تنسيقها. يحدد SwiftUI حجم عرض AppKit المُمثَّل. وتوفر NSScrollView المساحة المرئية. يحتوي عرض المستند فيها على النص، بينما تحدد NSTextContainer المساحة المتاحة لتخطيط النص. توثّق Apple عرض المستند بوصفه العرض الذي تمرره منطقة المحتوى في عرض التمرير. وثائق Apple لـdocumentView

ينشئ .frame(width:height:) في SwiftUI إطار تخطيط محيطًا. لكنه لا يضبط سلوك التفاف حاوية النص بشكل مستقل. وتوضح Apple أيضًا أن حدود العنصر الفرعي قد تمتد إلى ما وراء إطار محيط. لذلك يفيد الحدّ في التشخيص: فقد تكون أبعاد الحد كما طُلبت، حتى عندما يتصرف المحتوى المُمثَّل على نحو مختلف. وثائق Apple حول الإطارات

القيد الأساسي هو الملكية. تقول Apple صراحةً إن SwiftUI يتحكم في frame وbounds لعرض AppKit المُمثَّل؛ لذا فإن تغييرك لهاتين الخاصيتين يتعارض مع تحكم SwiftUI. أعد الحجم الذي تفضّله عبر دالة استدعاء تغيير الحجم الخاصة بالتمثيل. في هذا التنفيذ، العرض المُمثَّل هو عرض التمرير، بينما تدير AppKit مستند النص الداخلي. وثائق Apple لـNSViewRepresentable

اختر السلوك قبل تغيير أعلام التخطيط:

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

الصفان الأولان هما موضوع المهمة التي يغطيها المثال التنفيذي. أما الثالث فيتطلب سياسة مستقلة لارتفاع المحتوى؛ فقبول الارتفاع المقترح لا يحقق ذلك.

أنشئ محررًا ملتفًا ذا مساحة محدودة

أنشئ تطبيق SwiftUI لنظام macOS في Xcode واضبط هدف النشر على macOS 13 أو أحدث. أضف الملف التالي باسم FramedTextEditor.swift. يوفّر مقطع الشيفرة الثاني نقطة دخول التطبيق ومحتوى نموذجيًا؛ ويشكّل الملفان معًا المثال الكامل.

يتبع الإعداد ترتيب التمرير العمودي الذي توثقه Apple: يتمدد عرض النص عموديًا، ويتبع عرضه عرضَ العرض الفائق، وتتبع حاوية النص عرضَ عرض النص. دليل التجميع من Apple مؤرشف، لذا يستخدم المثال واجهات Swift الحالية ويختار TextKit 2 صراحةً عبر NSTextView(usingTextLayoutManager: true). توثّق Apple هذا الاختيار في عرضها التقديمي عن TextKit. دليل Apple لعرض نص قابل للتمرير، ما الجديد في TextKit وعروض النص

import AppKit
import SwiftUI

@MainActor
struct FramedTextEditor: NSViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator($text)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSScrollView()
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalScroller = false
        scrollView.autohidesScrollers = true

        let textView = NSTextView(usingTextLayoutManager: true)
        // This is the internal document, not the represented root view.
        textView.setFrameSize(scrollView.contentSize)
        textView.minSize = .zero
        textView.maxSize = NSSize(
            width: CGFloat.greatestFiniteMagnitude,
            height: CGFloat.greatestFiniteMagnitude
        )
        textView.isVerticallyResizable = true
        textView.isHorizontallyResizable = false
        textView.autoresizingMask = [.width]
        textView.isRichText = false
        textView.importsGraphics = false
        textView.font = .systemFont(ofSize: 14)
        textView.textContainerInset = NSSize(width: 8, height: 8)

        guard let container = textView.textContainer else {
            preconditionFailure("Expected a text container")
        }
        container.size = NSSize(
            width: max(1, scrollView.contentSize.width),
            height: CGFloat.greatestFiniteMagnitude
        )
        container.widthTracksTextView = true
        container.heightTracksTextView = false

        textView.string = text
        textView.delegate = context.coordinator
        scrollView.documentView = textView
        return scrollView
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        context.coordinator.text = $text
        guard let textView = nsView.documentView as? NSTextView else {
            preconditionFailure("Expected an NSTextView document")
        }
        if textView.string != text {
            textView.string = text
        }
    }

    func sizeThatFits(
        _ proposal: ProposedViewSize,
        nsView: NSScrollView,
        context: Context
    ) -> CGSize? {
        let ideal = CGSize(width: 320, height: 180)
        let proposed = proposal.replacingUnspecifiedDimensions(by: ideal)
        return CGSize(
            width: proposed.width.isFinite
                ? max(0, proposed.width) : ideal.width,
            height: proposed.height.isFinite
                ? max(0, proposed.height) : ideal.height
        )
    }

    static func dismantleNSView(
        _ nsView: NSScrollView,
        coordinator: Coordinator
    ) {
        (nsView.documentView as? NSTextView)?.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextViewDelegate {
        var text: Binding<String>

        init(_ text: Binding<String>) {
            self.text = text
            super.init()
        }

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else {
                return
            }
            if text.wrappedValue != textView.string {
                text.wrappedValue = textView.string
            }
        }
    }
}

استبدل نقطة دخول التطبيق التي أنشأها Xcode وعرض المحتوى بهذا الملف الثاني. أبقِ على تصريح واحد فقط لـ@main في الهدف. جميع التسميات والنصوص اصطناعية، ويعمل المثال بالكامل داخل نافذة التطبيق الخاصة به.

import SwiftUI

@main
@MainActor
struct FrameSizingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            EditorDemo()
        }
    }
}

@MainActor
struct EditorDemo: View {
    @State private var compact = false
    @State private var text = Array(
        repeating: "Sample note: arrange the blue tiles beside the green tiles.",
        count: 16
    ).joined(separator: "\n\n")

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Toggle("Compact editor", isOn: $compact)

            FramedTextEditor(text: $text)
                .frame(width: compact ? 280 : 420, height: 180)
                .border(Color.secondary)

            Text("Character count: \(text.count)")
                .font(.caption)
        }
        .padding(20)
        .frame(minWidth: 460, minHeight: 280, alignment: .topLeading)
    }
}

تم فحص هذين الملفين، لكن لم يتم تجميعهما أو تشغيلهما هنا: البيئة المتاحة هي Linux ولا تتضمن Swift أو أطر عمل macOS. أنشئ التطبيق وتحقق منه في Xcode باستخدام تسلسل التحقق أدناه.

كيف تعمل إعدادات تغيير الحجم والتفاف النص؟

اقبل المقترحات المحدودة من دون قياس المستند

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

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

لا تغيّر دالة الاستدعاء إطار عرض التمرير، ولا تستبدل النص، ولا تكتب حالة SwiftUI. وظيفتها الإجابة عن سؤال تغيير الحجم. لذلك لا يحدد طول النص ارتفاع المحرر الخارجي في هذا التنفيذ.

اجعل العرض يتبع مساحة العرض والارتفاع يتبع المحتوى

يؤدي إعدادا العرض غرضين مختلفين. تجعل قناع تغيير الحجم التلقائي .width لعرض النص الداخلي عرضه يستجيب للتغييرات في عرضه الفائق. وتجعل widthTracksTextView = true لحاوية النص عرض تخطيطها يستجيب للتغييرات في عرض النص. كلتا العلاقتين مهمتان عندما تضيق مساحة المحرر. وثائق Apple لـautoresizingMask، وثائق Apple لـwidthTracksTextView

عموديًا، تسمح isVerticallyResizable = true لعرض المستند بتغيير ارتفاعه ليتسع للنص. ويوفر الارتفاع الكبير لحاوية النص مساحة للتخطيط العمودي، بينما تتجنب heightTracksTextView = false ربط حد التخطيط بحجم عرض المستند في كل مرة يتغير فيها. ويظل عرض التمرير هو المساحة المرئية المحدودة. وثائق Apple حول تغيير الحجم عموديًا، وثائق Apple لـheightTracksTextView

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

افصل تحديثات النص عن التفاوض على الحجم

يمرر المنسّق تغييرات التحرير عبر textDidChange، ويربطها الربط بحالة العنصر الأب. ويتولى updateNSView معالجة التغييرات الواردة من SwiftUI. ويمنع فحص المساواة استبدال سلسلة المحرر عندما تعود القيمة نفسها بعد تعديل عادي. وثائق Apple لمفوّض تغييرات النص، وثائق Apple لـBinding

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

يضع المثال أنواع واجهة المستخدم والمنسّق ضمن @MainActor. يصف دليل التزامن في Swift العزل على الممثل الرئيسي بأنه آلية حماية حالة واجهة المستخدم وإلزام التعليمات البرمجية الموسومة بالتنفيذ على ذلك الممثل. لغة Swift البرمجية: التزامن

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

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

  1. راقب حد المحرر. مساحة المحرر المطلوبة مبدئيًا هي 420 × 180 نقطة.
  2. فعّل Compact editor. يفترض أن يصبح العرض 280 نقطة، مع بقاء الارتفاع 180 نقطة. وينبغي أن تلتف الجمل على أسطر أكثر.
  3. مرّر عبر الفقرات المضمّنة. ينبغي أن يتحرك المستند داخل الحد نفسه.
  4. اكتب أسطرًا إضافية. ينبغي أن يتغير عدد الأحرف من دون زيادة الارتفاع المطلوب للمحرر.
  5. احذف النص. ينبغي أن تحتفظ مساحة العرض بأبعادها المطلوبة رغم قلة المحتوى في المستند.
العَرَض أول ما ينبغي التحقق منه
الحد بالحجم الصحيح، لكن الأسطر الطويلة لا تلتف تحقق من قناع تغيير الحجم التلقائي لعرض المستند ومن widthTracksTextView
إضافة النص تكبّر التخطيط المحيط تأكد من أن التمثيل يعيد عرض التمرير ولا يعيد ارتفاع المحتوى من sizeThatFits
يتعذر الوصول إلى الأسطر اللاحقة تحقق من التمرير العمودي، وتغيير الحجم العمودي للمستند، وحد ارتفاع حاوية النص
يظل المحرر ضيقًا في تخطيط أب مختلف افحص العرض الذي يقترحه ذلك الأب؛ إذ تقبل دالة الاستدعاء المقترحات المحدودة بدلًا من اختلاق مساحة إضافية
يتغير موضع النص أثناء تحديثات حالة غير مرتبطة به تحقق من وجود إسنادات غير مشروطة إلى textView.string

هذه الفحوص تسلسل تشخيص مستنتج من علاقات تغيير الحجم المذكورة أعلاه، وليست نتائج اختبارات وقت التشغيل المبلّغ عنها.

ما الذي يتغير عند استخدام ارتفاع تحدده المحتويات أو إصدارات macOS الأقدم؟

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

حافظ على محرك النص المختار عند إضافة القياس. توضح Apple أن الوصول إلى layoutManager لنسخة NSTextView قد يؤدي إلى وضع التوافق مع TextKit 1. ويقابله الموثّق في TextKit 2 ضمن حدود الاستخدام لحاوية النص، NSTextLayoutManager.usageBoundsForTextContainer. لا يحتاج هذا المحرر المحدود إلى أي من واجهتي القياس. إرشادات Apple للانتقال بين إصدارات TextKit

حدّ الإصدار مهم أيضًا. تتوفر NSViewRepresentable منذ macOS 10.15، لكن دالة الاستدعاء sizeThatFits تتطلب macOS 13. في أهداف النشر الأقدم، أبقِ على ترتيب مستند التمرير واستخدم سلوك تغيير الحجم الافتراضي للتمثيل الأقدم، مع أولويات تغيير الحجم في AppKit أو حاوية مصممة لذلك الهدف. لا تتوقع أن تتحكم دالة الاستدعاء هذه في التخطيط هناك؛ تحقّق من ذلك التنفيذ على حدة. وثائق Apple للتمثيل، توفر دالة استدعاء تغيير الحجم لدى Apple

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

المصادر