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

يبني هذا الشرح تطبيقًا صغيرًا لنظام Mac لتحرير تسميات اصطناعية مثل Blue tile. الهدف هو مواصلة الكتابة داخل العنصر نفسه بينما يتغير نصه والقائمة المحيطة به. كما يوضح متى يتطلب استعادة التحديد حالة إضافية أو محرر AppKit.

لماذا يتحرك المؤشر عند تغيير عنصر في القائمة؟

ابدأ بالتحقق مما إذا كانت هوية العنصر تتغير مع نصه. يستخدم SwiftUI الهوية لربط قيم العرض المتعاقبة، وترتبط حالة العرض المستمرة بتلك الهوية. من الطبيعي إعادة تقييم body للعرض؛ أما استبدال هويته فينهي دورة حياته. تشرح Apple هذه العلاقة في إزالة الغموض عن SwiftUI.

في مجموعة قابلة للتحرير، تجنب تعريف الصف باستخدام تسميته القابلة للتحرير. مع id: \.self، تتغير هوية السلسلة عندما تتغير السلسلة. وقد تحدث المشكلة نفسها مع نموذج يتضمن تجزئته قيمة التسمية. كما أن تعريف العناصر القابلة للنقل بحسب موضعها في المصفوفة يربط الهوية بخانة بدلًا من العنصر المنطقي.

استخدم بدلًا من ذلك معرّفًا مخزنًا يظل ثابتًا بعد التعديلات. تدعم وثائق ForEach من Apple المجموعات ذات العناصر القابلة للتعريف أو مسار مفتاح معرّف صريح. والخيار العملي لهذا المحرر هو UUID مخزنًا ومرتبطًا بالعنصر.

أزل أيضًا المعدّلات مثل .id(item.text) أو .id(UUID()) من المحرر وأسلافه. توثّق Apple صراحةً أن تغيير القيمة الممررة إلى id(_:) يعيد ضبط هوية العرض، بما في ذلك حالته. ولا يمكن لإضافة معرّف ثابت للصف أن تعوّض تغيّر هوية أحد الأسلاف مع كل تعديل.

افصل بين الهوية والتركيز والتحديد

تجيب هذه العناصر الثلاثة عن أسئلة مختلفة. تحدد الهوية أي صف منطقي يستمر عبر التحديثات. ويحدد التركيز عنصر التحكم الذي يستقبل إدخال لوحة المفاتيح. أما التحديد فيحدد موضع الإدراج أو النص المحدد داخل عنصر التحكم.

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

تمثل TextSelection موضع إدراج أو نطاقًا محددًا أو تحديدات متعددة على macOS. يتوفر مُهيّئ TextField مع ربط بالتحديد بدءًا من macOS 15.

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

أنشئ قائمة بهوية ثابتة للعناصر

أنشئ تطبيق SwiftUI لنظام macOS في Xcode واضبط الحد الأدنى لإصدار النشر على macOS 15 أو أحدث. استخدم حزمة تطوير برمجيات تتضمن مُهيّئ الربط بالتحديد. استبدل إعلانات التطبيق وعرض المحتوى المُنشأة تلقائيًا بهذا المثال الكامل، مع الإبقاء على إعلان @main واحد فقط في الهدف.

import Foundation
import SwiftUI

struct LabelItem: Identifiable {
    let id: UUID
    var text: String

    init(id: UUID = UUID(), text: String) {
        self.id = id
        self.text = text
    }
}

@main
struct CaretListApp: App {
    var body: some Scene {
        WindowGroup {
            LabelListView()
        }
    }
}

struct LabelListView: View {
    @State private var items = [
        LabelItem(text: "Blue tile"),
        LabelItem(text: "Round pebble"),
        LabelItem(text: "Paper square")
    ]
    @FocusState private var focusedID: UUID?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Button("Add label") {
                    items.append(LabelItem(text: "New label"))
                }
                .keyboardShortcut("n", modifiers: [.command, .shift])

                Button("Reverse rows") {
                    items.reverse()
                }
                .keyboardShortcut("r", modifiers: [.command, .shift])
            }

            List {
                ForEach($items) { $item in
                    LabelRow(item: $item, focus: $focusedID)
                }
            }
        }
        .padding()
        .frame(minWidth: 420, minHeight: 260)
    }
}

struct LabelRow: View {
    @Binding var item: LabelItem
    let focus: FocusState<UUID?>.Binding
    @State private var selection: TextSelection? = nil

    var body: some View {
        TextField("Label", text: $item.text, selection: $selection)
            .textFieldStyle(.roundedBorder)
            .focused(focus, equals: item.id)
    }
}

يُنشأ id المخزن عند إنشاء العنصر. ويؤدي تغيير text أو عكس ترتيب المصفوفة الحالية إلى الحفاظ على ذلك المعرّف. يزوّد ForEach القائم على الربط كل صف بقيمة النموذج القابلة للتحرير، ويكتب الحقل مباشرةً إلى نص ذلك العنصر.

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

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

تمت مراجعة المثال مقارنةً بإعلانات واجهة البرمجة المسترجعة. ولم يُجمّع أو يُشغّل هنا لأن هذه البيئة لا تتضمن مترجم Swift أو بيئة تشغيل macOS.

حافظ على المعرّفات عند تحديث النموذج

يجب أن يحتفظ التحديث بمعرّفات العناصر التي ما زالت تمثل التسميات نفسها. يؤدي إنشاء LabelItem(text:) جديد لكل عنصر موجود إلى توليد معرّفات جديدة، حتى لو ظل كل النص الظاهر دون تغيير.

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

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

شخّص استمرار قفز المؤشر بترتيب مفيد

يستند دليل القرار التالي إلى الفصل الموثق بين الهوية والتركيز والتحديد. وهو يساعد على تضييق نطاق المشكلة قبل إضافة رمز للاستعادة.

السلوك الملحوظ أول ما يجب التحقق منه الخطوة التالية المناسبة
يُعاد ضبط المحرر بعد كل حرف معرّفات مشتقة من النص؛ أو تغيّر قيم .id(...) حافظ على ثبات معرّف العنصر وهويات الأسلاف
يتوقف وصول إدخال لوحة المفاتيح إلى الحقل تغيّر التركيز أو إزالة المحرر افحص focusedID وفروع العرض الشرطية
يبقى التركيز في الحقل لكن يتحرك المؤشر كتابة التطبيق إلى النص أو التحديد أزل عمليات إعادة الكتابة وتعيين التحديد غير المشروطة
تنتقل حالة التحرير مع موضع بعد إعادة الترتيب هوية مبنية على الفهرس عرّف العنصر المنطقي باستخدام معرّفه المخزن
يختفي التحديد بعد تصفية صف ثم إعادته دورة حياة الحالة المحلية للصف خزّن معلومات الاستعادة في مستوى أعلى من الصف القابل للإزالة

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

بعد ذلك، افحص التغييرات البنيوية. قد يؤدي التبديل بين فرعين شرطيين يحتوي كل منهما على محرر منفصل إلى استبدال هوية المحرر. يوضح شرح الهوية ودورة الحياة من Apple أهمية إبقاء محرر واحد حاضرًا عندما لا يتطلب الأمر سوى تغيير مظهره.

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

قرر ما الذي ينبغي أن يبقى بعد إزالة الصف

تلائم حالة التحديد المحلية للصف صفًا يظل موجودًا. لكنها لا تكفي وحدها لاستعادة الحالة عندما تزيل الواجهة ذلك الصف. وتشير ملاحظات إصدار macOS 15 من Apple أيضًا إلى أن ForEach قد يستعيد الحالة الدائمة التابعة لعروض فرعية غير مستخدمة.

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

تظل استعادة التركيز واستعادة التحديد عمليتين منفصلتين. إذا لم يعد العنصر موجودًا، فلا توجّه موضع المؤشر المحفوظ إلى العنصر الذي يشغل موضعه السابق في المصفوفة.

تعامل مع تغييرات النص البرمجية وفق سياسة صريحة للمؤشر

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

تقبل مُهيّئات TextSelection في SwiftUI موضع إدراج أو نطاقات تستند إلى String.Index. أنشئ تحديدًا جديدًا من السلسلة الناتجة عندما يغيّر تطبيقك تلك السلسلة. ولا تتعامل مع تحديد قديم كإشارة مرجعية رقمية دائمة.

تستخدم سلاسل Swift النوع String.Index، وقد يختلف عدد محارفها عن طول UTF-16 الذي تستخدمه NSString. هذه الفروق موثقة في السلاسل والمحارف. وهي مهمة عند التحويل بين تحديدات SwiftUI ونطاقات نص Cocoa.

لنفترض، لتوضيح قاعدة إدراج، أن النص هو Blue tile وأن المؤشر يقع مباشرةً بعد Blue. إذا أُدرج Soft في البداية، فينبغي أن يتحرك المؤشر خمس خانات إلى الأمام إذا كان الهدف الحفاظ على موضعه بالنسبة إلى النص الأصلي. أما إبقاء إزاحته الرقمية كما هي فسيضعه داخل جزء مختلف من التسمية.

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

متى ينبغي استخدام AppKit أو TextKit؟

في أهداف النشر الأقدم لنظام macOS، لا يتوفر مُهيّئ ربط التحديد في SwiftUI الخاص بـ macOS 15. لا يزال بإمكانك إصلاح الهوية غير المستقرة في حقل SwiftUI عادي. وإذا احتاج التطبيق أيضًا إلى تحكم صريح في التحديد، فإن أحد مسارات التكامل المدعومة هو محرر AppKit مغلف باستخدام NSViewRepresentable.

توثّق Apple أن NSTextView عرض للتحرير يجمع إمكانات TextKit. لا تحتاج إلى إنشاء محرك تخطيط مخصص لمجرد الاحتفاظ بموضع المؤشر. اختر عرضًا نصيًا مخصصًا عندما تحتاج إلى تحكم مباشر في حالة التحرير.

ينبغي أن يتبع الغلاف تسلسل التحديث التالي:

  1. أنشئ محرر AppKit في makeNSView(context:)، واربط المفوّض عبر منسّق.
  2. مرّر تعديلات المستخدم إلى النموذج المرتبط، وتغييرات التحديد إلى الحالة المرتبطة بمعرّف العنصر.
  3. في updateNSView، قارن نص النموذج بالنص الحالي للمحرر قبل إسناد أي قيمة.
  4. عند استبدال برمجي فعلي، احسب التحديد البديل وفق النص الناتج وطبّقه مرة واحدة.
  5. امنع رجوع إشعارات المفوّض الناتجة عن تحديث التطبيق، وعالج تركيب النص المُعلّم قبل تطبيق التحويلات المؤجلة.

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

للتعامل مع التحديثات أثناء التركيب، تشير hasMarkedText() إلى وجود نص مُعلّم. ومن السياسات العملية تأجيل تحويلات التطبيق غير الضرورية أثناء التركيب ثم تطبيقها بعد انتهائه.

لدى NSTextField في AppKit اعتبار آخر يتعلق بدورة الحياة: توثّق Apple أن عناصر التحكم البسيطة تشارك محرر الحقل الخاص بالنافذة. اربط التحديد المحفوظ بالعنصر وجلسة التحرير النشطة بدلًا من التعامل مع ذلك المحرر المشترك على أنه كائن دائم خاص بكل صف.

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

تحقّق من نتيجة التحرير في نافذتك

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

  1. ضع المؤشر بين Blue والمسافة التي تليه. اكتب small. تحقق من استمرار الكتابة عند موضع الإدراج المتوقع.
  2. حدّد tile واستبدله بـ square. تحقق من أن الاستبدال يطال النص المحدد فقط.
  3. أثناء التحرير، استدعِ Add label باستخدام Command-Shift-N. افحص العنصر النشط والتركيز والمؤشر كلًا على حدة.
  4. استدعِ Reverse rows باستخدام Command-Shift-R. تحقق من بقاء القيمة المعدّلة مرتبطة بمعرّف عنصرها الأصلي، ثم افحص التركيز والتحديد.
  5. في واجهتك الفعلية، اختبر إزالة الصف وظهوره مجددًا واستبدال النص برمجيًا وفق سياسة الاستعادة التي اخترتها.

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

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

المصادر