لإبقاء تمييزات البحث محدَّثة في NSTextView المُغلَّف بـ SwiftUI، أبطِل سمات العرض في TextKit 2 عند تغيّر الاستعلام أو النص، وأعِد بناء التمييزات من المحتوى الحالي في مدقّق العرض، واطلب إعادة العرض.
يبني هذا الشرح محرر Mac صغيرًا لتسميات اصطناعية مثل Tile A وCircle B. والمهمة المحددة هي تمييز التسميات المطابقة عندما يغيّر المستخدم حقل البحث أو يحرّر المستند داخل التطبيق نفسه.
ما الذي يجب تحديثه عند تغيير التمييز؟
هناك مدخلان مستقلان: أحرف المستند واستعلام التمييز. يؤدي تحرير المستند إلى تغيير محتوى النص. ويؤدي تغيير حقل بحث في SwiftUI إلى تغيير حالة التطبيق من دون أن يتغيّر بالضرورة أي شيء في نظام النص. يجب أن يربط الغلاف كلا الحدثين بأداة التمييز.
توفّر Apple renderingAttributesValidator بوصفها دالة الاستدعاء لتوفير سمات العرض لجزء تخطيط. كما توفّر invalidateRenderingAttributes(for:) لإبطال تلك السمات ضمن نطاق نصي. وتتيح واجهتا API هاتان تصميمًا يُخزَّن فيه الاستعلام كحالة حالية، وتُعاد فيه بناء التمييزات عند الحاجة إلى التحقق من العرض. مدقّق سمات العرض، إبطال سمات العرض.
إعادة العرض مسألة منفصلة. يؤدي تعيين needsDisplay إلى وضع علامة على العرض ليُرسَم في دورة حلقة الأحداث التالية. لكنه لا يحدّد بحد ذاته قاعدة التمييز التي تغيّرت ولا يحسب المطابقات الجديدة. لذلك يُبطل التنفيذ أدناه سمات العرض ويطلب إعادة العرض. NSView needsDisplay.
| الحدث | الحالة المطلوب تحديثها | الإجراء |
|---|---|---|
| تغيّر حقل البحث | الاستعلام الحالي لعرض النص | إبطال سمات العرض وطلب إعادة العرض |
| تغيّر نص المستند | يحتفظ AppKit بالنص المحرّر بالفعل | إبطال التمييزات عبر مفوّض النص |
| الحاجة إلى التحقق من جزء | سمات التمييز المؤقتة الخاصة به | مسح الخلفية السابقة وتطبيق المطابقات الحالية |
| فراغ الاستعلام | الاستعلام الحالي | مسح التمييزات السابقة من دون إضافة بدائل |
أبقِ عرض النص على TextKit 2
استخدم هدف نشر macOS 13 أو أحدث لهذا المثال، وتهيئة عرض النص صراحةً باستخدام NSTextView(usingTextLayoutManager: true). قدّمت Apple البُنى الملائمة لاختيار المحرك مع الانتقال الموصوف في جلسة WWDC22. وتوثّق واجهات API الأساسية لسمات العرض في AppKit والمستخدمة هنا على أنها متاحة بدءًا من macOS 12. ما الجديد في TextKit وعروض النص، NSTextLayoutManager.
تحقّق من textView.textLayoutManager عند تشخيص المحرك النشط. وقد يؤدي الوصول إلى layoutManager، بما في ذلك عبر حاوية النص، إلى تشغيل وضع التوافق مع TextKit 1. تصف Apple هذا الانتقال بأنه أحادي الاتجاه لعرض النص الحالي. أبقِ استعلامات مدير التخطيط القديمة خارج مسار TextKit 2. التوافق مع TextKit وتحديثه.
الحد الآخر هو تحكّم SwiftUI في التخطيط. تحذّر Apple من تغيير frame أو bounds للعرض المُمثَّل مباشرةً. في هذا المثال، الجذر المُمثَّل هو NSScrollView؛ ويكون تعيين الإطار الأولي من مسؤولية العرض الفرعي للمستند في AppKit. ويتحكم SwiftUI في الحجم الخارجي لعرض التمرير. NSViewRepresentable.
أنشئ مثالًا كاملًا قابلًا للتحرير
أنشئ تطبيق SwiftUI لنظام macOS في Xcode، واضبط هدف النشر على macOS 13 أو أحدث، واستبدل تعريفات التطبيق وعرض المحتوى المُنشأة بهذا الملف الواحد. أبقِ تعريفًا واحدًا بالضبط لتطبيق @main في الهدف.
يتولى SwiftUI إدارة استعلام البحث. ويتولى عرض النص في AppKit إدارة مستند هذا العرض التوضيحي طوال عمره. المطابقة حرفية عمدًا، وحساسة لحالة الأحرف، وغير متداخلة، كي يكون سلوك المثال متوقعًا مع التسميات الاصطناعية. هذا عرض توضيحي داخل الذاكرة؛ ولا ينفّذ حفظ المستندات.
import SwiftUI
import AppKit
@main
struct HighlightDemoApp: App {
var body: some Scene {
WindowGroup { ContentView() }
}
}
struct ContentView: View {
@State private var query = "Tile"
var body: some View {
VStack {
TextField("Highlight label", text: $query)
HighlightEditor(query: query)
}
.padding()
.frame(minWidth: 480, minHeight: 300)
}
}
struct HighlightEditor: NSViewRepresentable {
let query: String
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
let view = HighlightTextView(usingTextLayoutManager: true)
view.frame = scroll.contentView.bounds
view.autoresizingMask = [.width]
view.isVerticallyResizable = true
view.isHorizontallyResizable = false
view.textContainer?.widthTracksTextView = true
view.textContainer?.containerSize = NSSize(
width: scroll.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
view.isRichText = false
view.font = .systemFont(ofSize: 16)
view.string = "Tile A\nCircle B\nTile C\nSquare D"
view.delegate = context.coordinator
scroll.documentView = view
guard let manager = view.textLayoutManager else {
preconditionFailure("This demo requires TextKit 2")
}
manager.renderingAttributesValidator = { [weak view] manager, fragment in
view?.render(manager, fragment: fragment)
}
view.query = query
view.refreshHighlights()
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
guard let view = scroll.documentView as? HighlightTextView,
view.query != query else { return }
view.query = query
view.refreshHighlights()
}
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
func textDidChange(_ notification: Notification) {
(notification.object as? HighlightTextView)?.refreshHighlights()
}
}
}
@MainActor
final class HighlightTextView: NSTextView {
var query = ""
func refreshHighlights() {
guard let manager = textLayoutManager else { return }
manager.invalidateRenderingAttributes(for: manager.documentRange)
needsDisplay = true
}
func render(_ manager: NSTextLayoutManager, fragment: NSTextLayoutFragment) {
let fragmentRange = fragment.rangeInElement
manager.removeRenderingAttribute(.backgroundColor, for: fragmentRange)
guard !query.isEmpty, let content = manager.textContentManager else { return }
let text = string as NSString
var cursor = 0
while cursor < text.length {
let match = text.range(of: query, options: [.literal],
range: NSRange(location: cursor, length: text.length - cursor))
guard match.location != NSNotFound else { break }
guard let start = content.location(content.documentRange.location,
offsetBy: match.location),
let end = content.location(start, offsetBy: match.length),
let range = NSTextRange(location: start, end: end) else {
assertionFailure("Match does not map to current text content")
return
}
if let part = range.intersection(fragmentRange), !part.isEmpty {
manager.addRenderingAttribute(.backgroundColor,
value: NSColor.systemYellow.withAlphaComponent(0.35), for: part)
}
cursor = NSMaxRange(match)
}
}
}
تمت مراجعة الشيفرة ومقارنتها بتعريفات واجهات API المسترجعة، لكنها لم تُترجم أو تُشغّل باستخدام AppKit في هذه البيئة. تحققت فحوص حتمية منفصلة من إزاحات UTF-16 التوضيحية الواردة أدناه؛ لكنها لا تثبت سلوك واجهة المستخدم الرسومية.
لماذا يستخدم الغلاف مساري تحديث؟
تتلقى updateNSView حالة SwiftUI الجديدة وتنسخ الاستعلام المتغير إلى عرض النص الحالي. ويقرأ مدقّق العرض الاستعلام المخزّن في كل مرة يُشغّل فيها. وتشير مرجعيته الضعيفة إلى عرض AppKit بدلًا من التقاط قيمة أولية لممثّل قد تتضمن استعلامًا قديمًا. تعرّف Apple طريقة تحديث الممثّل بأنها الموضع الذي تُطبَّق فيه معلومات SwiftUI الجديدة. دورة حياة NSViewRepresentable.
أما الكتابة في المستند فتتبع مسار المنسّق. توثّق Apple textDidChange(_:) على أنها إشعار من المفوّض يفيد بتغيّر الأحرف أو سمات التنسيق. ويستجيب المنسّق بتحديث التمييزات فورًا؛ فلا يحتاج إلى ربط المستند كي يؤدي ذلك إلى تحديث SwiftUI. textDidChange(_:).
لا يُعيَّن نص العينة الأولي إلا عند الإنشاء. وتترك تحديثات الاستعلام اللاحقة المستند من دون تغيير. إذا أضفت لاحقًا ربطًا للمستند، فقارن المحتوى الوارد قبل استبداله وحدّد الجهة المالكة للتعديلات. إن إعادة تعيين نص العينة في كل تحديث ستكتب فوق مستند المستخدم.
لماذا يجب أن يحدث المسح قبل التحقق من فراغ الاستعلام؟
يزيل كل تحقق سمة .backgroundColor السابقة التي أضافتها أداة التمييز من الجزء قبل البحث عن المطابقات. لذلك ينفّذ الاستعلام الفارغ عملية التنظيف أيضًا. وهذا يعالج خطأً شائعًا في التنفيذ: يعمل البحث إلى أن يُفرَّغ الحقل، فتظل آخر مجموعة من التمييزات ظاهرة.
يستخدم المثال واجهتي API الموثّقتين للإزالة والإضافة لكل مفتاح، ما يحصر تغييراته في سمة الخلفية. ويفترض أن أداة التمييز هذه هي المالكة لخلفيات العرض؛ فإذا كانت ميزة أخرى توفّرها أيضًا، فاجمع تلك القواعد في سياسة عرض واحدة. إزالة سمة عرض، إضافة سمة عرض.
كذلك تُقاطع المطابقات مع الجزء الجاري التحقق منه. تعرّف Apple rangeInElement نسبةً إلى أصل المستند، وتُعيد intersection(_:) نطاق النص المشترك. يتيح ذلك للمطابقة أن تسهم فقط بالجزء التابع للجزء الحالي. نطاق الجزء، تقاطع نطاقات النص.
أبقِ إزاحات المطابقة متوافقة مع المستند الحالي
يبحث المثال في لقطة NSString ويستخدم NSRange الذي تُعيده. تحدد Apple أن الأطوال والفهارس والنطاقات في NSString تستخدم وحدات شيفرة UTF-16. كما تتيح Swift تمثيل UTF-16 للسلسلة عبر utf16؛ ولا يصلح عدد أحرف Swift بديلًا لهذه الإزاحات. الفهرسة في NSString، String.utf16.
لإجراء تحقق توضيحي، أضف الرمز المحايد والمسافة 🔷 إلى بداية مستند العينة. عندها تبدأ أول Tile عند إزاحة UTF-16 3، ويبلغ طولها 4. ويبدأ ظهورها الثاني عند الإزاحة 19. أدرج X في البداية، فتُصبح المواضع 5 و21. هذه أمثلة محسوبة، وليست ملاحظات من تشغيل AppKit.
حوّل كل مطابقة عبر مدير المحتوى الحالي: من بداية المستند إلى بداية المطابقة، ثم من هناك إلى نهايتها. لا تنشئ مواضع نص من فهارس أعداد صحيحة بنفسك، ولا تحتفظ بمواضع مطابقة قديمة بعد تعديلات المستند. تعرض Apple هذا التحويل عبر مدير المحتوى، الذي قد تُعيد واجهة الموقع فيه nil عند تجاوز الإزاحة حدود النطاق. إرشادات تحويل النطاق، مواقع تخزين محتوى النص.
تتقدم حلقة البحث باستخدام نهاية المطابقة المُعادة فعليًا. وتوثّق Apple أن نتائج البحث نسبية إلى بداية السلسلة، حتى عند البحث في نطاق أصغر. كما يمنع فحص الاستعلام الفارغ والتقدم إلى الأمام في المثال استمرار الحلقة من دون تقدم. أما لميزة بحث عامة موجّهة للمستخدم، فاختر دلالات مطابقة ملائمة للغة؛ إذ توصي Apple بالبحث القياسي الموطّن للنص المعروض للمستخدمين. البحث عن نطاق في NSString.
شخّص التمييزات القديمة بترتيب مفيد
استخدم هذا التسلسل للتمييز بين غياب تحديث الحالة ومشكلة في العرض أو النطاق. هذه فحوص متوقعة للمثال، وليست نتائج اختبارات مُبلّغًا عنها.
- ابدأ بـ
Tile. ينبغي تمييز كلتا تسميتيTileبلون خلفية. - غيّر الاستعلام إلى
Circle. ينبغي أن تختفي الخلفيات السابقة وأن تُميَّز تسميةCircle. - أفرغ الاستعلام. ينبغي أن تختفي جميع خلفيات البحث.
- أعد الاستعلام إلى
Tile، ثم حرّر إحدى التسميات المطابقة لتصبحSquare. ينبغي أن تتوقف تلك التسمية عن المطابقة. - أدرج
🔷قبل التسمية الأولى. ينبغي أن يتبع التمييز النص، لا أن يبقى عند الإزاحات العددية السابقة. - أضف أسطرًا كافية من التسميات الاصطناعية لإتاحة التمرير. غيّر الاستعلام، ثم مرّر المستند للتحقق من الأجزاء المعروضة حديثًا.
إذا تغيّر الاستعلام ولم يحدث شيء، فتحقّق أولًا من updateNSView والاستعلام المخزّن واستدعاء الإبطال. وإذا لم تنجح تعديلات المستند بينما نجحت تغييرات الاستعلام، فتحقّق من تعيين المفوّض ومن textDidChange(_:). وإذا بقيت المطابقات السابقة ملوّنة، فتحقّق من تنفيذ التنظيف قبل كل عودة مبكرة عند فراغ الاستعلام.
إذا أصبحت textLayoutManager تساوي nil، فتحقّق من وضع التوافق قبل إضافة استدعاءات إعادة الرسم. توفّر Apple إشعارات تبديل مدير التخطيط لتشخيص رجوع NSTextView إلى وضع بديل. يتطلب هذا العرض التوضيحي TextKit 2 ولا ينفّذ أداة تمييز لـ TextKit 1. تشخيصات وضع التوافق.
كيّف التنفيذ مع المستندات الأكبر
يعطي هذا التنفيذ الأولوية لمسار تحديث يسهل فحصه: فهو يبطل سمات عرض المستند ويبحث في السلسلة الحالية أثناء التحقق من كل جزء. ويؤدي ذلك إلى تكرار العمل، لذا يناسب عرضًا توضيحيًا صغيرًا بدلًا من أعباء العمل على مستندات كبيرة التي لم يُقَس أداؤها.
للمستندات الأكبر، احسب المطابقات مرة واحدة لكل إصدار من المستند والاستعلام، ثم اجعل التحقق يقاطع تلك المطابقات مع كل جزء. اربط المطابقات المخزّنة مؤقتًا بإصدار النص المحدد الذي نتجت عنه. وإذا جرى البحث بصورة غير متزامنة، فتخلّص من النتائج التي تغيّر إصدار المستند أو الاستعلام قبل تطبيقها.
أبقِ عمل العرض منفصلًا عن طلبات حساب هندسة المستند بأكمله. تميّز وثائق التخطيط لدى Apple بين إبطال العرض وإبطال التخطيط، وتوصي إرشاداتها الخاصة ببنية TextKit بتركيز عمل التخطيط على إطار العرض لأن ضمان تخطيط المحتوى خارج الشاشة قد يكون مكلفًا. ينبغي أن يبدأ تغيير استعلام لون الخلفية بإبطال العرض، من دون إضافة حسابات تخطيط المستند بأكمله تلقائيًا. عمليات NSTextLayoutManager، تعرّف على TextKit 2.
أبقِ تسلسل التحديث واضحًا مع نمو المحرر: استقبل الحالة الحالية، وأبطل سمات العرض المتأثرة، وامسح قيم التمييز القديمة، وأعِد بناء المطابقات استنادًا إلى النص الحالي. تحقّق من تغييرات الاستعلام وتعديلات المستند والمسح والتمرير كلٌّ على حدة، كي يشير كل إخفاق إلى جزء محدد من التنفيذ.
المصادر
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple
- renderingAttributesValidator — Apple
- invalidateRenderingAttributes(for:) — Apple
- needsDisplay — Apple
- ما الجديد في TextKit وعروض النص — Apple
- textDidChange(_:) — Apple
- removeRenderingAttribute(_:for:) — Apple
- addRenderingAttribute(_:value:for:) — Apple
- rangeInElement — Apple
- intersection(_:) — Apple
- NSString — Apple
- utf16 — Apple
- location(_:offsetBy:) — Apple
- range(of:options:range:) — Apple
- تعرّف على TextKit 2 — Apple