لتحديد الأشكال على لوحة رسم SwiftUI في Mac، اقرأ موضع المؤشر ضمن مساحة إحداثيات عرض اللوحة، ثم اعكس التحويل المستخدم في الرسم. عند استخدام التكبير والتحريك، اطرح إزاحة التحريك واقسم على مقدار التكبير قبل اختبار الأشكال.
يبني هذا البرنامج التعليمي لوحة رسم صغيرة لسطح المكتب تحتوي على مستطيلين اصطناعيين. ستُبرز المستطيل الموجود أسفل المؤشر، وتحدد مستطيلاً عند انتهاء إيماءة المؤشر، بما في ذلك بعد تغيير التكبير أو التحريك. تبقى جميع بيانات الرسم في الذاكرة.
ما مساحة الإحداثيات التي ينبغي أن تستقبل أحداث المؤشر؟
ميّز بين ثلاث مساحات: نظام الإحداثيات الذي يخزن أشكالك، ونظام الإحداثيات المحلي لعرض اللوحة، وأي إحداثيات نافذة يوفرها AppKit. لا تكون النقطة مفيدة إلا إذا عرفت المساحة التي تنتمي إليها.
يوفر onContinuousHover(coordinateSpace:perform:) في SwiftUI مواقع المؤشر ضمن مساحة الإحداثيات المطلوبة؛ والقيمة الافتراضية هي .local. يرسل .active(location) عندما يكون المؤشر داخل العرض، و.ended عندما يغادره. أرفقه مباشرة باللوحة كي تتطابق مساحته المحلية مع منفذ العرض الخاص بالرسم. وثائق Apple للتحويم المستمر.
تكون مساحة الإحداثيات المسماة مفيدة عندما يمتلك عنصر سلف منطقة التفاعل عن قصد. عندها ينبغي لمعالجي التحويم والإيماءات طلب المساحة المسماة نفسها. تعرّف Apple المساحات .local و.global و.named(_:) على أنها مساحات إحداثيات للتخطيط؛ فتسمية مساحة لا تحدد نموذج الرسم في التطبيق. وثائق Apple لمساحات الإحداثيات.
هذا التمييز مهم لأن Canvas ينفذ الرسم بنمط فوري. توضح Apple صراحةً أن العناصر المرسومة الفردية، بما فيها الرموز، لا تتلقى تفاعلات خاصة بها. والنهج المدعوم هنا هو معالجة التفاعل على اللوحة الحاوية وتنفيذ اختبار التصادم على النموذج بنفسك. وثائق Apple لـ Canvas.
كيف تعكس تحويل الرسم؟
اختر اصطلاحًا صريحًا للرسم. في هذا المثال، تزداد إحداثيات النموذج نحو اليمين والأسفل. يغيّر التكبير مقياس وحدات النموذج، ويكون التحريك إزاحة مقاسة بنقاط إحداثيات اللوحة المحلية. التحويل الأمامي ومعكوسه هما:
viewX = modelX * zoom + panX
viewY = modelY * zoom + panY
modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom
تنتج هذه المعادلات عن التحويل المختار. لتنفيذه في الرسم، انقل سياق الرسومات بمقدار التحريك ثم غيّر مقياسه بمقدار التكبير. توثق Apple أن هذه الأساليب تحدّث تحويل السياق وتؤثر في عمليات الرسم اللاحقة. يؤدي عكس ترتيبهما إلى تغيير التحويل، لذا أبقِ المعكوس مطابقًا لتسلسل الرسم الفعلي. وثائق الإزاحة، وثائق تغيير المقياس.
لإجراء تحقق توضيحي، استخدم مقدار التكبير 2، والتحريك (40, 30)، وموضع المؤشر المحلي (180, 110). ينتج عن طرح التحريك (140, 80)؛ وبالقسمة على مقدار التكبير نحصل على موضع النموذج (70, 40). تعيد عملية رسم نقطة النموذج هذه إلى (180, 110). لذا ينبغي إصابة مستطيل يحتوي إطاره في النموذج على (70, 40).
ضع هذا الحساب في نوع واحد مشترك. ينبغي أن يستخدمه كل من العارض وشيفرة التفاعل بدلاً من الاحتفاظ بصيغ إزاحة منفصلة. لا يقبل المثال سوى قيم تكبير موجبة ومنتهية؛ إذ لا يمكن عكس الصفر.
أنشئ مثالاً كاملاً للتحويم والتحديد
استخدم مشروع تطبيق SwiftUI لنظام macOS مع هدف نشر macOS 14 أو أحدث. يدعم هذا الهدف التحميل الزائد لـ CoordinateSpaceProtocol المستخدم في مُعدِّل التحويم. أضف ملف النموذج التالي، ثم استبدل تعريف التطبيق المُنشأ في المشروع بالملف الثاني. أبقِ تعريفًا واحدًا فقط لـ @main في الهدف. توفر واجهة التحويم وتوقيعها.
عرّف هندسة النموذج وتحويلها
أنشئ CanvasGeometry.swift بالمحتويات الكاملة التالية. تظل إطارات المستطيلات ضمن إحداثيات النموذج طوال التفاعل.
import SwiftUI
struct CanvasMapping {
let zoom: CGFloat
let pan: CGSize
init(zoom: CGFloat, pan: CGSize) {
precondition(zoom.isFinite && zoom > 0)
precondition(pan.width.isFinite && pan.height.isFinite)
self.zoom = zoom
self.pan = pan
}
func modelPoint(from viewPoint: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - pan.width) / zoom,
y: (viewPoint.y - pan.height) / zoom
)
}
func apply(to context: inout GraphicsContext) {
context.translateBy(x: pan.width, y: pan.height)
context.scaleBy(x: zoom, y: zoom)
}
}
struct CanvasItem {
let id: Int
let frame: CGRect
}
الهندسة والتحويل بنيتا Swift. وتسهل دلالات القيم تمرير القيم الحالية إلى شيفرة الرسم واختبار التصادم دون إدخال كائن آخر قابل للتغيير المشترك. يوثق دليل لغة Swift البُنى بوصفها أنواعًا قيمية. البُنى والفئات.
ارسم وتفاعل مع منفذ العرض نفسه
أنشئ PointerCanvasApp.swift باستبدال تعريف التطبيق المُنشأ. يستخدم هذا الملف الكامل الأنواع الموجودة في CanvasGeometry.swift.
import SwiftUI
@main
struct PointerCanvasApp: App {
var body: some Scene {
WindowGroup {
PointerCanvasView()
}
}
}
struct PointerCanvasView: View {
private let items = [
CanvasItem(id: 1, frame: CGRect(
x: 40, y: 30, width: 120, height: 80)),
CanvasItem(id: 2, frame: CGRect(
x: 120, y: 70, width: 120, height: 80))
]
@State private var zoom: CGFloat = 2
@State private var pan = CGSize(width: 40, height: 30)
@State private var pointerInView: CGPoint?
@State private var selectedID: Int?
private var mapping: CanvasMapping {
CanvasMapping(zoom: zoom, pan: pan)
}
private func hit(at viewPoint: CGPoint) -> Int? {
let point = mapping.modelPoint(from: viewPoint)
return items.reversed().first {
$0.frame.contains(point)
}?.id
}
var body: some View {
let currentMapping = mapping
let hoveredID = pointerInView.flatMap { hit(at: $0) }
VStack(alignment: .leading, spacing: 12) {
HStack {
Button("Zoom out") {
zoom = max(0.5, zoom / 2)
}
Button("Zoom in") {
zoom = min(4, zoom * 2)
}
Button("Pan right") {
pan.width += 20
}
Button("Reset view") {
zoom = 2
pan = CGSize(width: 40, height: 30)
}
Text("Selected: \(selectedID.map { String($0) } ?? "none")")
}
Canvas { context, _ in
var drawing = context
currentMapping.apply(to: &drawing)
for item in items {
let path = Path(item.frame)
drawing.fill(
path,
with: .color(
item.id == hoveredID ? .orange : .blue
)
)
if item.id == selectedID {
drawing.stroke(
path,
with: .color(.black),
lineWidth: 2 / currentMapping.zoom
)
}
}
}
.background(Color.white)
.contentShape(Rectangle())
.onContinuousHover(coordinateSpace: .local) { phase in
switch phase {
case .active(let point):
pointerInView = point
case .ended:
pointerInView = nil
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { value in
selectedID = hit(at: value.location)
}
)
.clipped()
}
.padding(16)
.frame(minWidth: 640, minHeight: 420)
}
}
تستخدم اللوحة منطقة تفاعل مستطيلة تشمل المساحة الفارغة بين الأشكال. يحدد contentShape(Rectangle()) شكل اختبار التصادم للعرض؛ أما الاختبار المنفصل frame.contains فيقرر أي عنصر اصطناعي يقع أسفل المؤشر. وثائق Apple لأشكال المحتوى.
يستخدم التحديد DragGesture بحد أدنى للمسافة يساوي صفرًا، ويقرأ location الخاص بحدث النهاية. والسلوك المحدد هنا هو اختيار المستطيل الموجود أسفل موضع الإفلات، حتى لو تحرك المؤشر أثناء الضغط. إنه محدد يعتمد على موضع الإفلات، وليس أداة تميّز النقر بدقة. توثق Apple عتبة المسافة الخاصة بالإيماءة وإعداد مساحة الإحداثيات، وتحدد location على أنها موضع الحدث الحالي. DragGesture، DragGesture.Value.
تُرسم العناصر بترتيب المصفوفة ويُختبر التصادم معها بترتيب عكسي. لذلك يفوز المستطيل 2 في موضع التداخل، بما يطابق الترتيب المرئي في هذا المثال. يؤدي النقر في مساحة فارغة عمدًا إلى إلغاء التحديد. ولا تتغير إطارات الأشكال أو محتويات المصفوفة.
يمتلك العرض حالة المؤشر والتحديد، بينما يقرأ العارض هذه الحالة. توصي Apple بتعريف الحالة الخاصة في أعلى عرض يحتاج إليها، وتمرير روابط عندما تحتاج العناصر الفرعية إلى صلاحية الكتابة. إذا تولى عنصر أب لاحقًا ملكية مجموعة العناصر أو التحديد، فانقل الملكية إليه ومرر القيم أو الروابط المطلوبة إلى هذا العرض. وثائق Apple للحالة.
كيف تتحقق من التحويل قبل إضافة مزيد من الإيماءات؟
ابدأ بنقاط نموذج معروفة واحسب مواضعها المحلية المتوقعة. تستخدم نقاط التحقق التالية قيم التكبير والتحريك الأولية في المثال:
| نقطة النموذج | نقطة اللوحة المحلية المتوقعة | النتيجة المتوقعة |
|---|---|---|
(70, 40) |
(180, 110) |
المستطيل 1 |
(140, 90) |
(320, 210) |
المستطيل 2 في منطقة التداخل |
(10, 10) |
(60, 50) |
لا يوجد مستطيل |
تُقاس المواضع المحلية من اللوحة نفسها؛ أما شريط الأدوات والهوامش الخارجية فتقع خارج تلك المساحة. حرّك المؤشر عبر كلا المستطيلين، وأفلته فوق منطقة التداخل، ثم جرّب Zoom out وPan right. ينبغي أن يتبع التمييز هندسة العرض عند كل إعداد.
نُفذ حساب المعكوس التوضيحي و12 عملية تحقق من التحويل الأمامي والعكسي باستخدام حسابات Python. لم يتوفر مترجم Swift أو بيئة تشغيل واجهة macOS في بيئة البحث، لذا فُحص تطبيق Swift لكنه لم يُترجم أو يُختبر تفاعليًا.
إذا أخفق اختبار، فاستخدم العَرَض لتضييق نطاق السبب:
- تشير الإزاحة الثابتة إلى أن معالج الإدخال مرتبط بعرض مختلف، أو إلى إغفال التحريك.
- يشير الخطأ الذي يزداد مع المسافة إلى غياب تحويل المقياس أو عدم تطابق ترتيب التحويلات.
- تشير النتيجة المعكوسة رأسيًا إلى قلب إضافي لمحور y أو إلى نقطة AppKit غير محوّلة.
- يشير التحديد الخاطئ في مناطق التداخل فقط إلى عدم اتساق ترتيب الرسم واختبار التصادم.
خزّن نقطة التحويم المحلية الخام، كما يفعل المثال، واشتق نقطة النموذج من التحويل الحالي. يحول ذلك دون الاحتفاظ بنقطة تحويم في إحداثيات النموذج حُسبت باستخدام مقدار تكبير سابق. ويستخدم التحديد موقع حدث الإيماءة نفسه، لذا لا يعتمد على استدعاء سابق لرد نداء التحويم.
ما الذي يتغير عندما يأتي الحدث من AppKit؟
إذا كان عرض AppKit مخصص موجودًا يتولى تفاعل الفأرة، فحوّل الحدث عند تلك الحدود قبل تطبيق معكوس الرسم. يستخدم NSEvent.locationInWindow عادةً إحداثيات الأساس للنافذة المرتبطة. تشير Apple إلى أن حدثًا بنافذة nil قد يحتوي بدلاً من ذلك على إحداثيات الشاشة، لذا تحقّق أولًا من أن الحدث ينتمي إلى نافذة العرض المستقبِل. وثائق Apple لموضع الحدث.
داخل NSView المستقبِل، يحوّل التعبير convert(event.locationInWindow, from: nil) نقطة النافذة إلى إحداثيات العرض المحلية. وتوثق Apple nil بوصفه مصدر إحداثيات النافذة لهذا التحويل. لا تطبق معكوس التحويل الذي يستخدمه عارض ذلك العرض إلا بعد ذلك. وثائق Apple لتحويل النقاط.
تحقق من الاتجاه عند حدود العرض. يكون أصل عرض AppKit عادي في الزاوية السفلية اليسرى، ويزداد محور y إلى الأعلى؛ أما العرض الذي يعيد isFlipped فيه القيمة true فيكون أصله في الزاوية العلوية اليسرى ويزداد محور y إلى الأسفل. يمكن لسطح رسم AppKit مخصص اعتماد الاصطلاح الأخير لمطابقة هذا النموذج. دع تحويل العرض يتولى الفروق بين أنظمة إحداثيات العروض، وتجنب إضافة قلب آخر بعد ذلك. وثائق Apple للإحداثيات المعكوسة.
عند إتاحة مثل هذا العرض عبر NSViewRepresentable، أبقِ معالجة أحداث AppKit في العرض المُمثّل وأبلغ عن النتائج من خلال رد نداء أو منسق صريح. حدّث هذا الاتصال أثناء التحديثات ونظّف الموارد المملوكة أثناء إنهاء العمل. تتحكم SwiftUI في تخطيط العرض المُمثّل، لذا لا تضبط frame أو bounds فيه للتعويض عن أخطاء التحويل. وثائق Apple لدورة حياة العرض المُمثّل.
في مثال SwiftUI الخالص، أبقِ التفاعل مرتبطًا باللوحة. وإذا حل سطح AppKit محلها، فاجعل ذلك السطح مالك منطقة التفاعل. ينبغي لتحويل الإحداثيات أن يحوّل حدثًا يقدمه مالكه الحالي؛ ولا ينبغي أن يتطلب تمرير نسخة ثانية إلى معالج تحديد آخر يتنافس معه.
كيف ينبغي أن يتطور التحويل مع لوحة أكبر؟
يدعم المثال عمدًا التكبير المنتظم الموجب والإزاحة. إذا أضفت دورانًا أو تغييرًا غير منتظم للمقياس أو أصلًا مختلفًا للرسم، فاستبدل المعكوس البسيط بمعكوس تحويل الرسم الكامل. ارفض التحويلات غير القابلة للعكس قبل التفاعل، واحتفظ باختبارات أمامية وعكسية باستخدام نقاط معروفة.
لإضافة سحب العناصر مستقبلًا، سجّل العنصر وإزاحة الإمساك ضمن إحداثيات النموذج عند بدء الإيماءة. حدّث العنصر نفسه طوال الإيماءة؛ وحدد صراحةً ما إذا كان تحويل منفذ العرض سيبقى ثابتًا أم سيتغير أثناء السحب. إن تكرار اختبار التصادم لاختيار عنصر جديد مع كل حركة سيغيّر ملكية التفاعل كلما تداخلت الأشكال.
استخدم مساحة إحداثيات واحدة لمنفذ العرض، وتحويل رسم واحدًا، ومعكوسًا مطابقًا له. عندما تتوافق هذه العناصر، يمكن للتحويم والتحديد العمل على هندسة نموذج ثابتة بينما يتغير مظهرها مع التكبير والتحريك.
المصادر
- Canvas — Apple
- onContinuousHover(coordinateSpace:perform:) — Apple
- CoordinateSpaceProtocol — Apple
- translateBy(x:y:) — Apple
- scaleBy(x:y:) — Apple
- البُنى والفئات — Swift.org
- contentShape(_:eoFill:) — Apple
- DragGesture — Apple
- DragGesture.Value — Apple
- State — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- isFlipped — Apple
- NSViewRepresentable — Apple