عيّن NSTextFieldDelegate، ونفّذ controlTextDidEndEditing(_:)، وانسخ stringValue للحقل إلى رابط SwiftUI. يربط المنسّق هذا الاستدعاء بحالة العرض، بحيث يمكن تحديث تسمية بعد انتهاء التحرير.

يبني هذا البرنامج التعليمي تطبيق Mac صغيرًا يتضمن تسميتين قابلتين للتحرير وملخصًا يعرض القيم المثبّتة لهما. تتمثل المهمة في إبقاء الملخص كما هو أثناء الكتابة، ثم تحديثه عندما ينهي AppKit جلسة التحرير. توثّق Apple استدعاء انتهاء التحرير على أنه يحدث بعد أن يثبّت عنصر التحكم تغييراته. توثيق Apple لـ controlTextDidEndEditing(_:)

أي حدث ينبغي أن يحدّث العرض؟

بالنسبة إلى NSTextField موجود، استخدم استدعاء المفوّض الخاص بتحرير عنصر التحكم. يتلقى المفوّض إشعارًا تكون قيمة object فيه هي عنصر التحكم الذي أرسله، ما يتيح للمعالج تحديد الحقل وقراءة قيمته. توثيق Apple لـ textDidEndEditingNotification

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

السلوك المطلوب المسار المناسب
تحديث قيمة مثبّتة عند انتهاء حقل نص AppKit من التحرير نفّذ controlTextDidEndEditing(_:) في مفوّضه.
إرسال معالج موجود لـ target-action في AppKit عند اكتمال التحرير اضبط الخاصية sendsActionOnEndEditing للخلية.
التعامل مع الإرسال من TextField أصلي في SwiftUI أرفق .onSubmit لسلوك الإرسال الموثّق الخاص به.

لهذه المسارات عقود مختلفة. توثّق Apple أن تفعيل sendsActionOnEndEditing يرسل الإجراء بعد الضغط على Return أو الانتقال من الحقل باستخدام Tab أو النقر في حقل نص آخر؛ أما تعطيله فيقصر الإجراء على Return. وتوثّق SwiftUI أن .onSubmit لـ TextField يستجيب للضغط على Return. لذلك، فهو لا يوفّر استدعاءً عامًا عند فقدان التركيز لحقل AppKit مضمّن. توثيق Apple لـ sendsActionOnEndEditing، توثيق Apple لـ onSubmit(of:_:)

في هذا التطبيق، المفوّض هو المكان الوحيد الذي يثبّت نص الحقل. إذا أضفت لاحقًا معالجًا لـ target-action أيضًا، فتجنّب تنفيذ الأثر الجانبي نفسه الخاص بالتثبيت في كلا المعالجين.

أنشئ مثالًا كاملًا باستخدام SwiftUI وAppKit

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

اختيار macOS 13 هدفًا لهذا المثال قرار خاص بالمشروع، وليس إصدار بدء توفر الجسر: تدرج Apple NSViewRepresentable على أنه متاح بدءًا من macOS 10.15. يجعل AppKit هذا التنفيذ خاصًا بهدف macOS. توثيق Apple لـ NSViewRepresentable

import SwiftUI
import AppKit

@main
@MainActor
struct EditingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@MainActor
struct ContentView: View {
    @State private var cardTitle = "Blue Card"
    @State private var shelfLabel = "Shelf A"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Card title")
            CommitTextField(value: $cardTitle, label: "Card title")
                .frame(height: 24)

            Text("Shelf label")
            CommitTextField(value: $shelfLabel, label: "Shelf label")
                .frame(height: 24)

            Divider()

            Text("Committed values")
                .font(.headline)
            Text("Card: \(cardTitle)")
            Text("Shelf: \(shelfLabel)")
        }
        .padding(20)
        .frame(minWidth: 420)
    }
}

@MainActor
struct CommitTextField: NSViewRepresentable {
    @Binding var value: String
    let label: String

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> NSTextField {
        let field = NSTextField(string: value)
        field.placeholderString = label
        field.delegate = context.coordinator
        return field
    }

    func updateNSView(_ field: NSTextField, context: Context) {
        context.coordinator.parent = self
        field.placeholderString = label
        field.isEnabled = context.environment.isEnabled

        // Leave the active editing buffer alone.
        if field.currentEditor() == nil, field.stringValue != value {
            field.stringValue = value
        }
    }

    static func dismantleNSView(
        _ field: NSTextField,
        coordinator: Coordinator
    ) {
        field.delegate = nil
    }

    @MainActor
    final class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: CommitTextField

        init(parent: CommitTextField) {
            self.parent = parent
            super.init()
        }

        func controlTextDidEndEditing(_ notification: Notification) {
            guard let field = notification.object as? NSTextField else {
                return
            }

            let committedValue = field.stringValue
            if parent.value != committedValue {
                parent.value = committedValue
            }
        }
    }
}

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

كيف تصل القيمة إلى الملخص

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

ينشئ makeNSView عنصر تحكم AppKit القابل للتحرير ويعيّن المنسّق مفوّضًا له. يتعامل updateNSView مع التغييرات المتدفقة من SwiftUI إلى عنصر التحكم هذا. أما استدعاء المفوّض فيتعامل مع الاتجاه المعاكس. تستخدم إرشادات التكامل من Apple ترتيب المنسّق والرابط نفسه لاستضافة عروض AppKit. جلسة Apple «Use SwiftUI with AppKit»

يحافظ الإسناد context.coordinator.parent = self على حداثة القيمة المخزنة في المنسّق الخاص بالعنصر الممثّل. يستمر المنسّق طوال عمر العرض المستضاف، بينما قد تتغير قيم العنصر الممثّل. تعرض Apple صراحةً تحديث تلك القيمة المخزنة أثناء updateNSView. شرح Apple لدورة حياة المنسّق

حافظ على المسودة أثناء تحديثات SwiftUI

الشرط الأساسي هو field.currentEditor() == nil. يترك هذا المثال عمدًا النص غير المنتهي داخل AppKit، بينما تحتفظ SwiftUI بآخر قيمة مثبّتة. من دون شرط التحقق من التحرير، قد يؤدي نسخ القيمة المثبّتة إلى الحقل أثناء تحديث SwiftUI إلى استبدال المسودة غير المنتهية.

توثّق Apple أن currentEditor() يعيد محرّر الحقل لعنصر التحكم، أو nil إذا لم يكن له محرّر. محرّر الحقل هو NSTextView مشترك بين عناصر التحكم في النافذة. لهذا السبب، فإن التحقق فقط مما إذا كانت قيمة firstResponder في النافذة تساوي NSTextField بديل غير مناسب لسؤال عنصر التحكم عن محرّره. توثيق Apple لـ currentEditor()

ينتج عن ذلك سياسة تصميم صريحة:

الحالة سلوك المثال
الحقل خامل ويتغير رابطه انسخ قيمة الرابط إلى الحقل عند التحديث التالي للعنصر الممثّل.
للحقل محرّر نشط احتفظ بمخزن التحرير المؤقت.
ينتهي التحرير بنص مختلف اكتب النص المكتمل إلى الرابط.
ينتهي التحرير بنص لم يتغير تخطَّ إسناد الحالة الزائد.

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

لا يلزم تخصيص تخطيط TextKit لهذه المهمة. الحد الفاصل المعني هو جلسة تحرير عنصر التحكم ومحرّر حقله. يشرح دليل Apple لمعمارية النص العلاقة بين NSTextField ومفوّضه ومحرّر الحقل NSTextView المشترك؛ هذا الدليل مؤرشف، بينما يؤكد مرجع currentEditor() الحالي نموذج المحرّر المشترك. دليل Apple «Text Editing»، مرجع Apple الحالي لمحرّر الحقل

تحقّق من سلوك اكتمال التحرير في التطبيق

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

  1. استبدل Blue Card بـ Green Card. أثناء الكتابة، ينبغي أن يظل الملخص يعرض Card: Blue Card.
  2. اضغط Return. ينبغي أن يتغير الملخص إلى Card: Green Card.
  3. حرّر عنوان البطاقة مجددًا، ثم انقر حقل Shelf label. ينبغي أن يظهر عنوان البطاقة المكتمل في الملخص.
  4. حرّر قيمة الرف، ثم اضغط Tab. تحقّق من تحديث ملخص الرف وافحص التنقل بلوحة المفاتيح.
  5. ثبّت قيمة فارغة. ينبغي أن يعرض الملخص المقابل بادئته من دون قيمة؛ فالمثال يقبل السلاسل الفارغة.
  6. أنهِ تحريرًا من دون تغيير نصه. ينبغي أن يمنع فحص المساواة إسنادًا آخر للحالة.

قد ينهي Return التحرير من دون إحداث انتقال التركيز الذي تتوقعه. توثّق Apple أنه بعد إشعار انتهاء التحرير، يحاول NSTextField تنفيذ الإجراء ومعالجة Return الأخرى، وقد يحدد النص. ويحاول Tab وShift-Tab الانتقال إلى عنصر العرض التالي أو السابق ضمن ترتيب التنقل بلوحة المفاتيح. تعامل مع اكتمال التحرير ووجهة التركيز كسلوكين منفصلين عند فحص الواجهة. توثيق Apple لـ textDidEndEditing(_:)

تشخيص عدم وصول الاستدعاء

أولًا، تحقّق من أن field.delegate لا يزال يشير إلى المنسّق. الخاصية ضعيفة، لذا لن يبقى كائن مفوّض مؤقت متاحًا من دون مالك منفصل. يوفّر منسّق العنصر الممثّل مدة الحياة اللازمة هنا. توثيق Apple لـ delegate، شرح Apple لدورة حياة المنسّق

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

إذا أضاف تطبيقك التحقق من الصحة، فتحقّق من control(_:textShouldEndEditing:). تحدد قيمة الإرجاع الموثّقة له ما إذا كان يمكن إنهاء التحرير. لا ينبغي التعامل مع محاولة مرفوضة لمغادرة الحقل على أنها تثبيت ناجح. توثيق Apple لـ control(_:textShouldEndEditing:)

وافحص أيضًا مسارات الإلغاء. تنص Apple صراحةً على أن استدعاء abortEditing() للتخلّص من التعديلات المعلقة لا يستدعي controlTextDidEndEditing(_:). لذلك، تحتاج عملية الإلغاء إلى سلوك حالة مقصود خاص بها. توثيق Apple لاستدعاء انتهاء التحرير

اطلب اكتمال التحرير من أمر صريح في التطبيق

بالنسبة إلى أمر مستقبلي باسم Done يشير إلى هذا الحقل، يكون مسار المستجيب المدعوم هو طلب استدعاء makeFirstResponder(nil) من النافذة الخاصة به. تحقّق من القيمة المنطقية المُعادة قبل المتابعة: توثّق Apple أن المستجيب الحالي قد يرفض التخلي عن الاستجابة، ما يجعل الطريقة تعيد false. استخدم النافذة الفعلية للحقل بدلًا من افتراض أن النافذة الرئيسية للتطبيق تخص هذا المحرر. توثيق Apple لـ makeFirstResponder(_:)

نفّذ الوصول إلى AppKit واستدعاءات الواجهة في هذا الجسر على الممثل الرئيسي. تصريح المفوّض الحالي معزول على الممثل الرئيسي، وتوثّق Swift أن MainActor يستخدم منفّذًا مكافئًا لقائمة الانتظار الرئيسية للإرسال. يعبّر المثال مباشرةً عن هذا العزل باستخدام @MainActor؛ ولا يضيف مهمة مؤجلة بين اكتمال التحرير والكتابة إلى الرابط. تصريح Apple للاستدعاء، توثيق Swift لـ MainActor

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

المصادر