لإرسال أمر من قائمة SwiftUI إلى عنصر NSTextView النشط، حدّد المستجيب الأول في النافذة الرئيسية عند تشغيل الأمر، ثم أرسل الإجراء إلى ذلك العرض. يوضّح هذا البرنامج التعليمي مثالًا بمحررين، حيث يؤثر أمر Select First Line في المحرر الذي يحمل التركيز فقط.
المهمة هي إضافة إجراء مخصص إلى قائمة داخل تطبيق macOS تملكه. يستخدم المثال نصًا اصطناعيًا وفئة فرعية من NSTextView يملكها التطبيق وواجهات SwiftUI وAppKit العامة. ويتمثل قرار التصميم الأساسي في تحديد ما إذا كان ينبغي للأمر اتباع سلسلة المستجيبين في AppKit أو اشتراط وجود محرر نشط محدد.
اختر المقصود بعبارة «عرض النص النشط»
في هذا المثال، المحرر النشط هو مثيل SampleTextView الذي يشكّل حاليًا المستجيب الأول في النافذة الرئيسية. تعرّف Apple النافذة الرئيسية بأنها النافذة التي تستقبل أحداث لوحة المفاتيح؛ وتحدّد خاصية firstResponder المستجيب الذي يستقبل الأحداث والإجراءات عادةً أولًا. تمنح قراءة هاتين الخاصيتين وقت استدعاء الأمر وجهته الحالية. توثيق Apple لـ keyWindow، توثيق Apple لـ firstResponder.
تدعم AppKit أيضًا الإجراءات غير المستهدفة: مرّر nil كهدف إلى NSApplication.sendAction(_:to:from:)، وسيبحث التطبيق عن مستجيب ينفّذ المحدِّد. يبدأ هذا البحث بالمستجيب الأول في النافذة الرئيسية، لكنه قد يتابع عبر المفوّضين والنافذة الرئيسية قبل الوصول إلى معالجات على مستوى التطبيق. لذلك، لا يعني الإجراء غير المستهدف «نفّذ على عرض النص النشط في هذه النافذة الرئيسية فقط». توثيق Apple لتوجيه الإجراءات.
| السلوك المطلوب | خيار التوجيه | النتيجة |
|---|---|---|
| دع AppKit تعثر على معالج مؤهل | أرسل الإجراء باستخدام to: nil |
يُطبّق السلوك المعتاد للرجوع عبر سلسلة المستجيبين. |
| التأثير في المحرر النشط الذي يملكه التطبيق فقط | حدّد المستجيب الأول وتحقق من نوعه، ثم عيّنه هدفًا صراحةً | يتوقف الأمر إذا لم يكن ذلك المحرر نشطًا. |
| العمل على محرر معين بصرف النظر عن التركيز | عيّن ذلك المحرر هدفًا | هذا تفاعل يختلف عن أمر المحرر النشط. |
يختار التنفيذ أدناه السلوك الثاني. كما أن التحقق من الفئة الفرعية المخصصة يمنع محرر حقل نص غير ذي صلة من أن يصبح الوجهة لمجرد أنه عرض نص.
أنشئ مثال SwiftUI وAppKit كاملًا
أنشئ مشروع تطبيق macOS باستخدام SwiftUI، واجعل هدف النشر macOS 13 أو أحدث لهذا المثال. استبدل نقطة دخول التطبيق المُنشأة بملف Swift واحد كما يلي، وأزل أي تصريح آخر لـ @main. يوفّر البرنامج المحررين ونصهما الأولي والقائمة ومعالج الإجراء.
تُعد NSViewRepresentable في SwiftUI الوسيلة المدعومة لتضمين عرض من AppKit. وتوفّر أوامر المشهد محتويات القائمة، فيما تنشئ CommandMenu قائمة خاصة بالتطبيق في شريط قوائم macOS. توثيق Apple لـ NSViewRepresentable، أوامر المشهد، قوائم الأوامر.
import SwiftUI
import AppKit
import Foundation
@main
@MainActor
struct RoutingDemoApp: App {
var body: some Scene {
WindowGroup("Routing Demo") {
ContentView()
}
.commands {
CommandMenu("Sample Actions") {
Button("Select First Line") {
Self.routeSelectFirstLine()
}
}
}
}
private static func routeSelectFirstLine() {
let application = NSApplication.shared
guard let editor = application.keyWindow?.firstResponder
as? SampleTextView else {
return
}
_ = application.sendAction(
#selector(SampleTextView.selectFirstSampleLine(_:)),
to: editor,
from: nil
)
}
}
@MainActor
struct ContentView: View {
@State private var windowLabel = "Sample Window"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Window label", text: $windowLabel)
HStack(spacing: 12) {
SampleEditor(initialText: "Sample Alpha\nAlpha details")
SampleEditor(initialText: "Sample Beta\nBeta details")
}
Text("Click an editor, then use Sample Actions.")
.font(.caption)
}
.padding()
.frame(minWidth: 640, minHeight: 320)
}
}
@MainActor
final class SampleTextView: NSTextView {
@objc func selectFirstSampleLine(_ sender: Any?) {
guard isSelectable, !hasMarkedText() else { return }
let contents = string as NSString
guard contents.length > 0 else { return }
let firstLine = contents.lineRange(
for: NSRange(location: 0, length: 0)
)
setSelectedRange(firstLine)
scrollRangeToVisible(firstLine)
}
}
@MainActor
struct SampleEditor: NSViewRepresentable {
let initialText: String
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.borderType = .bezelBorder
let editor = SampleTextView(
frame: NSRect(x: 0, y: 0, width: 300, height: 240)
)
editor.isEditable = true
editor.isSelectable = true
editor.isRichText = false
editor.allowsUndo = true
editor.font = NSFont.monospacedSystemFont(
ofSize: 14, weight: .regular
)
editor.minSize = .zero
editor.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
editor.isVerticallyResizable = true
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainer?.containerSize = NSSize(
width: 300,
height: CGFloat.greatestFiniteMagnitude
)
editor.textContainer?.widthTracksTextView = true
editor.string = initialText
scrollView.documentView = editor
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
// AppKit owns the editable contents in this example.
}
}
هذا مثال تطبيق كامل بمحتوى محررات محفوظ في الذاكرة. تمت مراجعته بالاستناد إلى توثيق واجهات البرمجة الذي جرى استرجاعه؛ ولم يُترجم أو يُشغّل هنا لأن البيئة المتاحة هي Linux ولا تتضمن سلسلة أدوات Swift.
لماذا يصل المحدِّد إلى المحرر المقصود
تحصل وظيفة التوجيه على المستجيب الأول مباشرةً قبل الإرسال. وهي لا تقبل إلا SampleTextView وتستخدم ذلك الكائن بعينه هدفًا للإجراء. لذلك، لا تحتفظ القائمة بالمحرر الذي نُقر عليه سابقًا، ولا تبحث في نافذة أخرى إذا أخفق التحقق من النوع.
تستخدم طريقة الإجراء @objc لأن إرسال الإجراءات إلى الأهداف يعتمد على محددات Objective-C. ويشير التعبير #selector إلى الطريقة المُعلنة بدلًا من سلسلة محدِّد مكتوبة يدويًا. توثّق Apple هذا الجمع لاستدعاء طرق Swift عبر واجهات برمجة التطبيقات التي تستخدم آلية الهدف والإجراء في Objective-C. استخدام ميزات وقت تشغيل Objective-C في Swift.
تشير القيمة المعادة من sendAction إلى ما إذا كانت الرسالة قد أُرسلت. لكنها لا تشير إلى ما إذا كان المعالج قد غيّر التحديد: فقد يستقبل المعالج الرسالة ثم يعود دون إجراء شيء إذا كان المحتوى فارغًا أو كان هناك نص معلَّم. يتجاهل هذا المثال نتيجة الإرسال عمدًا لأن الوجهة والطريقة معروفتان مسبقًا. توثيق Apple للقيمة المعادة من sendAction.
ما الذي يحدده أمر التحديد فعلًا
تطلب الطريقة من NSString.lineRange(for:) تحديد نطاق السطر المنطقي الذي يحتوي على بداية السلسلة. ويشمل هذا النطاق محرف إنهاء السطر، إن وُجد. وبذلك يحدد السطر الأول المفصول بأسطر جديدة، حتى إذا التفّ ذلك السطر عبر عدة صفوف معروضة. توثيق Apple لنطاقات الأسطر.
في النص المقدّم، يحدد المحرر الأيسر Sample Alpha ومحرف السطر الجديد الذي يليه؛ ويحدد المحرر الأيمن Sample Beta ومحرف السطر الجديد الذي يليه. ويستبدل الأمر التحديد السابق، لكنه لا يغيّر النص.
تطبّق setSelectedRange(_:) التحديد، وتجعل scrollRangeToVisible(_:) أحرفه الأولى ظاهرة في العرض. يمنع التحقق من فراغ المحتوى محاولة إجراء تحديد دون وجود أي نص. أما التحقق من وجود نص معلَّم فهو سياسة صريحة في هذا المثال: أجّل إجراء التحديد المخصص هذا ما دام تركيب الإدخال نشطًا. نطاقات التحديد، تمرير نطاق إلى العرض، النص المعلَّم.
افصل بين إنشاء العرض والتحرير والتركيز
تنشئ الواجهة القابلة للتمثيل NSScrollView وتثبّت عرض النص المخصص باعتباره عرض المستند. يتمدد المحرر عموديًا، فيما تتابع حاوية النص عرضه. وهذا يتبع الترتيب الذي توثّقه Apple لعرض النص القابل للتمرير. يظهر هذا الترتيب في دليل AppKit المؤرشف من Apple؛ أما واجهة SwiftUI والجوانب المتعلقة بالإجراءات المشار إليها هنا فتستخدم التوثيق الحالي. وضع كائن NSTextView في NSScrollView.
تتحكم SwiftUI في تخطيط عرض التمرير الممثَّل. ويضبط الكود الهندسة الأولية لعرض المستند المتداخل وسلوك تغيير حجمه في AppKit؛ ولا يعيد تعيين إطار عرض التمرير الممثَّل باستمرار. تحذّر Apple تحديدًا من تغيير خصائص التخطيط التي تديرها SwiftUI لواجهة قابلة للتمثيل. إرشادات Apple لتخطيط الواجهات القابلة للتمثيل.
يعيّن المثال initialText عند الإنشاء فقط. وتترك طريقة التحديث الفارغة عمدًا الكتابة اللاحقة تحت تحكم AppKit. إذا احتاج تطبيقك إلى نموذج مستند في SwiftUI، فأضف منسقًا ومزامنة تستند إلى المفوّض، مع تحديد سياسة للتغييرات الواردة في النموذج أثناء التحرير. فإعادة تعيين السلسلة الأولية في كل تحديث ستمحو المحتوى الحالي للمستخدم. تشرح Apple أن المنسقين يشكّلون جسرًا لنقل تفاعلات AppKit إلى SwiftUI. إرشادات Apple لتنسيق الواجهات القابلة للتمثيل.
يؤدي النقر على محرر إلى منحه التركيز في هذا الشرح. إذا احتاج تطبيقك إلى نقل التركيز برمجيًا، فاستخدم makeFirstResponder(_:) للنافذة الحاوية بعد أن ينتمي العرض إلى تلك النافذة. وتنص Apple صراحةً على عدم استدعاء becomeFirstResponder() لعرض النص مباشرةً. تجنب طلب التركيز في كل تحديث لـ SwiftUI، لأن ذلك سيعيد توجيه وجهة التحرير للمستخدم باستمرار. إرشادات Apple لتركيز عرض النص.
حافظ على إعداد TextKit الحالي لديك
لا يتطلب توجيه القائمة تغيير محرك تخطيط النص. يستخدم هذا المثال NSTextView(frame:)؛ وتوثّق Apple أن هذا المُهيئ يستخدم NSLayoutManager افتراضيًا. ويتوفر الإنشاء الصريح باستخدام NSTextLayoutManager منذ macOS 12. توثيق Apple لتهيئة عرض النص.
إذا كان لديك محرر موجود يستخدم TextKit 2، فاحتفظ بطريقة إنشائه وأضف الإجراء إلى فئته الفرعية. تجنب قراءة layoutManager لمجرد تحديد موقع المحرر أو توجيه إجراء إليه: توثّق Apple أن الوصول إلى هذه الخاصية في عرض نص يستخدم TextKit 2 قد يحوّله إلى وضع التوافق. لا يحتاج كود التوجيه إلا إلى المستجيب وطريقة الإجراء. توثيق Apple لوضع التوافق في NSTextView.
تحقّق من التوجيه وتعامل مع الأوامر غير المتاحة
اتبع هذه الخطوات للتحقق من السلوك المقصود على macOS. تمثل هذه نتائج متوقعة مستنتجة من التنفيذ، وليست نتائج تشغيل مُبلّغًا عنها.
- انقر داخل المحرر الأيسر، ثم اختر
Sample Actions→Select First Line. ينبغي تحديد السطر المنطقي الأول في المحرر الأيسر. - انقر داخل المحرر الأيمن واستدعِ الأمر نفسه. ينبغي أن يتغير التحديد في المحرر الأيمن فقط.
- انقر داخل
Window labelواستدعِ الأمر. ينبغي أن يبقى المحرران المخصصان دون تغيير لأن المستجيب الأول لا يجتاز التحقق من الفئة الفرعية. - احذف كل محتوى أحد المحررين واستدعِ الأمر فيه. ينبغي أن ينتهي التنفيذ عند شرط المحتوى الفارغ دون تغيير النص.
- أضف عدة أسطر، ومرّر الصفحة إلى الأسفل، ثم استدعِ الأمر. ينبغي تحديد السطر الأول وتمريره إلى العرض.
يبقى الأمر مفعّلًا في هذا المثال المبسط، حتى عندما يكون التركيز في مكان آخر. ويحدد شرط التحقق عند الاستدعاء ما إذا كان سيُنفّذ. إن قراءة keyWindow?.firstResponder داخل تعبير SwiftUI من نوع .disabled وحدها لا تنشئ آلية لتحديث ذلك التعبير كلما تغيّر التركيز في AppKit.
في واجهة SwiftUI أكبر، انشر قدرة الأمر المناسبة عبر حالة تراعي التركيز، وراقبها باستخدام @FocusedValue، واستنتج شرط .disabled للزر من تلك الحالة. ويجب أن تحافظ واجهة AppKit الوسيطة على توافق القدرة مع التركيز الفعلي للمحرر. واحتفظ أيضًا بالتحقق عند الاستدعاء. توثّق Apple أن FocusedValue تراقب القيم من العرض المركّز أو أسلافه. القيم المرتبطة بالتركيز، تعطيل التفاعل.
إذا أنشأت بدلًا من ذلك عنصر قائمة في AppKit وكان المحرر هدفه، فإن validateMenuItem(_:) توفّر آلية التحقق في AppKit. وإضافة هذه الطريقة إلى هذه الفئة الفرعية لا تكفي وحدها للتحقق من زر SwiftUI: فإجراء الزر عبارة عن إغلاق يستدعي لاحقًا إجراءً آخر. وتشترط Apple أن يكون كائن التحقق هو هدف عنصر القائمة. توثيق Apple للتحقق من عناصر القائمة.
حدّد سياسة الهدف قبل إضافة مزيد من الأوامر. استخدم إجراءً غير مستهدف عندما يكون الرجوع عبر سلسلة المستجيبين جزءًا من السلوك المطلوب؛ وحدّد المستجيب الأول واستهدفه عندما تكون العملية خاصة بالمحرر النشط وحده. يتيح هذا الاختيار بقاء القائمة نفسها متوقعة مع انتقال المستخدمين بين عروض النص وعناصر التحكم الأخرى.
المصادر
sendAction(_:to:from:)— ApplekeyWindow— ApplefirstResponder— AppleNSViewRepresentable— Applecommands(content:)— AppleCommandMenu— Apple- استخدام ميزات وقت تشغيل Objective-C في Swift — Apple
lineRange(for:)— ApplesetSelectedRange(_:)— ApplescrollRangeToVisible(_:)— ApplehasMarkedText()— Apple- وضع كائن NSTextView في NSScrollView — Apple
NSTextView— Appleinit(frame:textContainer:)— AppleFocusedValue— Appledisabled(_:)— ApplevalidateMenuItem(_:)— Apple