لتحرير قائمة SwiftUI مُرشَّحة من دون تغيير العنصر الخطأ، احتفظ بمصفوفة أصلية واحدة واربط كل صف ظاهر بها باستخدام معرّف ثابت. يتيح لك مثال macOS هذا تصفية التسميات الاصطناعية وإعادة تسميتها وتبديل حالتها وإزالتها في تطبيقك.
لماذا لا ينشئ الترشيح وحده صفوفًا قابلة للتحرير
تعيد filter(_:) في Swift مجموعة جديدة تضم العناصر المطابقة بترتيبها الأصلي. لكنها لا تنشئ علاقة قابلة للكتابة بين تلك المجموعة ومصدرها. عندما تكون لديك مصفوفة من البنى، فإن تغيير عنصر في مصفوفة مُرشَّحة مخزنة على نحو مستقل لا يحدّث المصفوفة الأصلية. تستند هذه الاستنتاجات إلى السلوك الموثق للدالة filter(_:) ودلالات القيمة للبنى في Swift.
لنفترض مثلًا أن المصفوفة الأصلية تحتوي على Grid وTile وRing، لكن المرشّح يعرض Ring فقط. موضعه الظاهر هو صفر، أما موضعه الأصلي فهو اثنان. واستخدام الموضع الظاهر للكتابة في المصفوفة الأصلية سيغيّر Grid. وقد يؤدي ترتيب النتائج المعروضة إلى عدم تطابق مماثل.
يوفر الرابط صلة القراءة والكتابة الناقصة. تصف Apple Binding بأنه اتصال ببيانات مملوكة لمكان آخر، وتوفر init(get:set:) للوصول المخصص. هنا، يبحث الإغلاقان عن العنصر الأصلي باستخدام المعرّف كلما نُفّذا. تحدد المجموعة المُرشَّحة الصفوف التي تظهر، وتتلقى المصفوفة الأصلية التعديلات. راجع Binding وinit(get:set:).
اختر معرّفًا ثابتًا قبل إنشاء المحرر
يحتاج كل عنصر إلى معرّف يظل دون تغيير عند تعديل اسمه أو إعداد تضمينه أو موضعه في المصفوفة. يصف بروتوكول Apple Identifiable هوية الكيان الثابتة، بينما تستخدم ForEach بيانات قابلة للتعريف أو معرّفًا صريحًا للتمييز بين العروض التي تنشئها. راجع Identifiable وForEach.
ينشئ المثال كل UUID مرة واحدة في خاصية مخزنة let id. وتظل هوية التسمية كما هي عند إعادة تسميتها. تجنب اشتقاق الهوية من الاسم القابل للتعديل أو إنشاء UUID جديد كلما قُرئ المعرّف. وتأكد أيضًا من أن لكل عنصر معرّفًا فريدًا: إذ يعثر البحث عمدًا على أول معرّف مطابق، لذا فإن تكرار المعرّفات يجعل وجهة الكتابة ملتبسة.
استخدم دليل القرار هذا لاختيار تدفق البيانات:
| متطلبات الواجهة | نهج الربط |
|---|---|
| عرض مجموعة قيم من دون تغييرها | مرّر القيم إلى صفوف للقراءة فقط. |
| تحرير المجموعة الأصلية مباشرة | استخدم دعم SwiftUI لربط المجموعات حيثما كان ذلك مناسبًا. |
| تحرير نتائج تختلف مواضعها عن المصدر | حدّد العنصر الأصلي باستخدام معرّف ثابت. |
| إبقاء محرر مفتوحًا بعد خروج العنصر من النتائج | امنح المحرر اختيارًا خاصًا به وسياسة خاصة بعمره. |
ينفذ ما تبقى الحالة الثالثة. كما يحدد ما يحدث عند اختفاء صف، كي لا يتحول العنصر المفقود مطلقًا إلى فهرس مصفوفة غير صالح.
أنشئ مثالًا كاملًا لنظام macOS
أنشئ مشروع تطبيق macOS في Xcode باستخدام SwiftUI، واضبط هدف النشر على macOS 13 أو أحدث لهذا المثال. استبدل تعريفَي التطبيق وعرض المحتوى اللذين أنشأهما Xcode بالملف الواحد التالي. أبقِ نقطة دخول واحدة فقط تحمل @main؛ توضح وثائق App من Apple هذا الشرط وبنية WindowGroup المستخدمة هنا.
يملك العنصر الأب المصفوفة الأصلية في @State خاص، ويتلقى كل عنصر فرعي @Binding. يتبع ذلك النمط الموثق من Apple في مشاركة الحالة مع العروض الفرعية. لا تتضمن العينة سوى تسميات اصطناعية محلية، وتحتفظ ببياناتها في الذاكرة.
import SwiftUI
import Foundation
private struct LabelItem: Identifiable, Sendable {
let id = UUID()
var name: String
var isIncluded: Bool
}
@main
struct FilteredLabelsApp: App {
var body: some Scene {
WindowGroup {
LabelEditor()
}
}
}
@MainActor
private struct LabelEditor: View {
@State private var items = [
LabelItem(name: "Grid", isIncluded: true),
LabelItem(name: "Tile", isIncluded: false),
LabelItem(name: "Ring", isIncluded: true)
]
@State private var query = ""
@State private var includedOnly = false
@State private var notice = ""
private var visibleItems: [LabelItem] {
items.filter { item in
(!includedOnly || item.isIncluded) &&
(query.isEmpty ||
item.name.localizedCaseInsensitiveContains(query))
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Filter labels", text: $query)
Toggle("Included only", isOn: $includedOnly)
Button("Reverse original order") {
items.reverse()
}
List {
ForEach(visibleItems) { snapshot in
LabelRow(item: itemBinding(for: snapshot)) {
items.removeAll { $0.id == snapshot.id }
}
}
}
Text("Original array:")
.font(.headline)
Text(items.map(\.name).joined(separator: " · "))
Text(notice)
}
.padding()
.frame(minWidth: 540, minHeight: 360)
}
private func itemBinding(
for snapshot: LabelItem
) -> Binding<LabelItem> {
let id = snapshot.id
return Binding(
get: {
items.first { $0.id == id } ?? snapshot
},
set: { updated in
guard let index = items.firstIndex(where: {
$0.id == id
}) else {
notice = "Edit skipped: label was removed."
return
}
items[index].name = updated.name
items[index].isIncluded = updated.isIncluded
notice = ""
}
)
}
}
@MainActor
private struct LabelRow: View {
@Binding var item: LabelItem
let remove: () -> Void
var body: some View {
HStack {
TextField("Label name", text: $item.name)
Toggle("Included", isOn: $item.isIncluded)
Button("Remove", action: remove)
}
}
}
هذا مثال تطبيق كامل، وليس مقتطفًا للصقه داخل عرض موجود. وقد جرت مراجعته بمقارنته بوثائق واجهات API المسترجعة، لكنه لم يُترجم هنا لأن هذه البيئة لا تتضمن سلسلة أدوات Swift ولا حزمة تطوير macOS.
كيف يصل التعديل إلى المصفوفة الأصلية
تعيد الخاصية المحسوبة visibleItems حساب العضوية استنادًا إلى المصفوفة الأصلية وإعدادات المرشح الحالية. تتلقى ForEach هذه النتائج وتنشئ صفًا لكل عنصر مطابق. كما أن الترشيح قبل إنشاء الصفوف يتجنب وضع فرع شرطي ينتج صفرًا أو صفًا واحدًا داخل كل تكرار، وهو نمط تناقشه Apple في إرشادات أداء ForEach.
عندما يقرأ الصف رابطه، يبحث المُلتقِط عن معرّفه في المصفوفة الأصلية الحالية. ولا تُستخدم اللقطة إلا كقيمة احتياطية لعنصر أُزيل بالفعل. وما دام العنصر موجودًا، يعيد المُلتقِط القيمة الأصلية الحالية بدلًا من اللقطة التي التُقطت عند إنشاء الصف.
عندما يحرر المستخدم Label name، يُسقط $item.name رابط العنصر إلى خاصية الاسم القابلة للكتابة. توثق Apple إسقاط الخاصية هذا عبر الرمز المنخفض ذي العضو الديناميكي لـ Binding. ثم يعثر المُرسِل على الفهرس الأصلي الحالي للعنصر ويحدّث حقوله القابلة للتحرير، مع الحفاظ على معرّفه المخزّن.
يستخدم البحث firstIndex(where:)، التي تعيد فهرسًا اختياريًا وتعيد nil عند عدم وجود تطابق. ويتحقق الكود من النتيجة قبل استخدام الفهرس. ولأنه يعيد البحث عند كل كتابة، فإن عكس ترتيب المصفوفة الأصلية لا يترك المُرسِل محتفظًا بفهرس قديم. راجع وثائق firstIndex(where:) من Apple.
ماذا يحدث عند اختفاء صف؟
هناك حالتان مختلفتان. إذا جعل تعديلٌ عنصرًا غير مطابق للمرشح، يظل العنصر موجودًا في المصفوفة الأصلية. يُحتفظ بالتعديل، لكن الصف يخرج من النتائج. مثلًا، يؤدي إيقاف Included أثناء تفعيل Included only إلى إخفاء الصف؛ ويؤدي إيقاف المرشح إلى إظهاره مجددًا.
إذا أُزيل العنصر من المصفوفة الأصلية، يُرفض أيّ كتابة لاحقة عبر رابط قديم. تعرض العينة الرسالة Edit skipped: label was removed. ولا تعيد إدراج اللقطة في المصفوفة. إن إعادة لقطة من المُلتقِط ورفض الكتابات للعناصر المفقودة سياستان صريحتان في هذا المثال، وليستا ضمانتين توفرهما SwiftUI.
يتطلب الترشيح الفوري للأسماء قرارًا بشأن التفاعل أيضًا. توثق Apple أن TextField المرتبط بسلسلة يحدّث قيمته باستمرار أثناء التحرير. لذلك قد يختفي الصف فور توقف اسمه الجديد عن مطابقة الاستعلام. لإعادة التسمية دون انقطاع، امسح الاستعلام أولًا، أو صمّم محررًا منفصلًا يحفظ المسودة عند تنفيذ إجراء صريح. راجع TextField.
تحقّق من السلوك باختبار سريع على سطح المكتب
تصف الفحوص التالية السلوك المتوقع استنادًا إلى العينة؛ وهي ليست نتائج تنفيذ مُبلّغًا عنها. ابدأ تشغيلًا جديدًا لعرض التسميات الأولية، ثم نفّذ الخطوات بالترتيب.
| الإجراء | النتيجة المتوقعة |
|---|---|
أدخل Ring في Filter labels. |
يظهر Ring فقط. |
أعد تسميته إلى Ring Two. |
تصبح قراءة المصفوفة الأصلية Grid · Tile · Ring Two. |
امسح الاستعلام وانقر Reverse original order. |
تصبح القراءة Ring Two · Tile · Grid. |
أعد تسمية Grid إلى Grid Two. |
تتغير القيمة الأصلية لذلك العنصر وحده. |
فعّل Included only، ثم أوقف Included لـ Grid Two. |
يختفي ذلك الصف، لكن اسمه يظل ظاهرًا في قراءة المصفوفة الأصلية. |
أوقف Included only. |
يعود Grid Two مع بقاء مفتاح التبديل متوقفًا. |
أدخل Tile، ثم انقر زر Remove الخاص به. |
يختفي الصف ومدخله من المصفوفة الأصلية. |
إذا تغيّر العنصر الخطأ، فتحقق مما إذا كان يُستخدم إزاحة العنصر المعروض بوصفها فهرسًا في المصفوفة الأصلية. وإذا اختفت التعديلات بعد مسح المرشح، فتحقق مما إذا كان الصف يملك نسخة منفصلة في @State. وإذا تصرف الصف المعاد تسميته كأنه عنصر جديد، فتحقق مما إذا كان معرّفه يعتمد على محتوى قابل للتحرير. تستهدف هذه الفحوص العلاقات الثلاث التي يبقيها المثال واضحة: ملكية المصدر، ووجهة الكتابة، وهوية الصف.
التزم بحدود macOS وواجهات API
بالنسبة إلى التسميات القصيرة القابلة للتحرير، توفر TextField في SwiftUI الواجهة المطلوبة. وقد يحتاج محرر مستندات أغنى إلى NSTextView من AppKit، التي تدعم النص المنسق وتتكامل مع مكونات نظام النص مثل NSTextStorage وNSTextLayoutManager. يغيّر ذلك أداة التحرير؛ لكن التطبيق يظل بحاجة إلى تحديد عنصر النموذج الأصلي الذي يستقبل كل تعديل. راجع NSTextView.
الجسر المدعوم لعرض AppKit ضمن تسلسل SwiftUI الهرمي هو NSViewRepresentable. توثق Apple كيفية إنشاء العرض المُمثَّل وتحديثه، واستخدام منسّق لتمرير التفاعلات. عند إنشاء محرر مخصص مستقبلًا، حافظ على وجهة الكتابة نفسها القائمة على المعرّف الثابت. ودع SwiftUI يدير تخطيط العرض المُمثَّل بدلًا من تغيير إطاره أو حدوده التي يديرها مباشرة. راجع NSViewRepresentable.
يمتد AppKit أيضًا إلى حدود TextKit: توثق Apple أن الوصول إلى NSTextView.layoutManager قد ينقل العرض إلى وضع التوافق باستخدام NSLayoutManager. تحقق من توفر واجهات API النصية المحددة وسلوكها قبل اعتمادها. ولا يتطلب حل ربط المجموعة نفسه أي تخصيص لـ TextKit. راجع مناقشة التوافق في NSTextView.
بالنسبة إلى المجموعات الأكبر، ضع في الحسبان كلفة البحث. توثق Apple تعقيدًا خطيًا لكل من filter(_:) وfirstIndex(where:)؛ لذا قد تصبح عمليات البحث المتكررة لكل صف مؤثرة مع نمو المجموعة. قِس أداء واجهتك قبل إدخال مخبأ يربط المعرّفات بالفهارس، وأعد بناء أي مخبأ من هذا النوع كلما غيّرت التعديلات البنيوية الفهارس. راجع filter(_:) وfirstIndex(where:).
أبقِ الوصول إلى واجهة المستخدم عبر هذه الروابط المحسوبة ضمن الممثل الرئيسي، كما يفعل المثال. توضح وثائق مُهيئ الرابط من Apple أن إمكانية الوصول عبر رابط محسوب تعتمد على عزل إغلاقيه. ولا يؤدي تمرير رابط إلى مكان آخر إلى إزالة تلك القيود. راجع init(get:set:).
في محرر التسميات المُرشَّح هذا، احتفظ بمصفوفة أصلية واحدة، واستخدم معرّفات ثابتة للصفوف، وحدد هدف الكتابة لحظة التحرير. ثم اختر سلوك الاختفاء عن قصد: يحتفظ العنصر الذي أخرجه المرشح بتعديله، بينما يرفض العنصر المُزال أي كتابات لاحقة مع إظهار توضيح للمستخدم.
المصادر
- filter(_:) — Apple
- Structures and Classes — Swift.org
- Binding — Apple
- init(get:set:) — Apple
- Identifiable — Apple
- ForEach — Apple
- App — Apple
- State — Apple
- firstIndex(where:) — Apple
- TextField — Apple
- NSTextView — Apple
- NSViewRepresentable — Apple