Mac SwiftUI कैनवास में आकार चुनने के लिए, पॉइंटर को कैनवास व्यू के कोऑर्डिनेट स्पेस में पढ़ें, फिर ड्रॉइंग के लिए इस्तेमाल किए गए ट्रांसफ़ॉर्म को उलटें। ज़ूम और पैन के साथ, अपने आकारों की जाँच करने से पहले पैन ऑफ़सेट घटाएँ और ज़ूम से भाग दें।

इस ट्यूटोरियल में दो कृत्रिम आयतों वाला एक छोटा डेस्कटॉप कैनवास बनाया गया है। आप पॉइंटर के नीचे वाले आयत को हाइलाइट करेंगे और पॉइंटर जेस्चर समाप्त होने पर एक आयत चुनेंगे, ज़ूम या पैन बदलने के बाद भी। ड्रॉइंग का सारा डेटा मेमोरी में रहता है।

पॉइंटर इवेंट किस कोऑर्डिनेट स्पेस में लेने चाहिए?

तीन स्पेस अलग रखें: वह कोऑर्डिनेट सिस्टम जिसमें आपके आकार संग्रहीत हैं, कैनवास व्यू का लोकल कोऑर्डिनेट सिस्टम, और AppKit से मिलने वाले विंडो निर्देशांक। कोई बिंदु तभी उपयोगी है, जब आपको पता हो कि वह किस स्पेस का है।

SwiftUI का onContinuousHover(coordinateSpace:perform:) अनुरोधित कोऑर्डिनेट स्पेस में पॉइंटर की लोकेशन देता है; इसका डिफ़ॉल्ट .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) आता है, वह हिट होना चाहिए।

यह गणना एक साझा टाइप में रखें। रेंडरर और इंटरैक्शन कोड में अलग-अलग ऑफ़सेट फ़ॉर्मूले रखने के बजाय, दोनों को एक ही मैपिंग का उपयोग करना चाहिए। उदाहरण में केवल सीमित और धनात्मक ज़ूम मान स्वीकार किए गए हैं; शून्य को इन्वर्ट नहीं किया जा सकता।

पूरा होवर और चयन उदाहरण बनाएँ

macOS 14 या उसके बाद के डिप्लॉयमेंट टारगेट वाला macOS SwiftUI ऐप प्रोजेक्ट इस्तेमाल करें। यह टारगेट होवर मॉडिफ़ायर में इस्तेमाल किए गए CoordinateSpaceProtocol ओवरलोड को सपोर्ट करता है। नीचे दी गई मॉडल फ़ाइल जोड़ें, फिर प्रोजेक्ट की जनरेट की गई ऐप घोषणा को दूसरी फ़ाइल से बदलें। टारगेट में ठीक एक @main घोषणा रखें। Hover API की उपलब्धता और सिग्नेचर।

मॉडल ज्योमेट्री और उसकी मैपिंग परिभाषित करें

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 की भाषा मार्गदर्शिका स्ट्रक्चर्स को वैल्यू टाइप के रूप में दस्तावेज़ित करती है। Structures and Classes।

एक ही व्यूपोर्ट से ड्रॉ और इंटरैक्ट करें

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 का content-shape दस्तावेज़ीकरण।

चयन में शून्य न्यूनतम दूरी वाला DragGesture इस्तेमाल होता है और समाप्ति इवेंट का location पढ़ा जाता है। इसका निर्धारित व्यवहार रिलीज़ की स्थिति के नीचे वाला आयत चुनना है, भले ही दबाए रखने के दौरान पॉइंटर हिला हो। यह रिलीज़-स्थिति चयनकर्ता है, सख़्त क्लिक पहचानकर्ता नहीं। Apple जेस्चर की दूरी सीमा और कोऑर्डिनेट स्पेस कॉन्फ़िगरेशन का दस्तावेज़ीकरण करता है, और location को मौजूदा इवेंट स्थिति बताता है। DragGesture, DragGesture.Value।

आइटम ऐरे के क्रम में ड्रॉ किए जाते हैं और उल्टे क्रम में जाँचे जाते हैं। इसलिए जहाँ दोनों आयत ओवरलैप करते हैं, वहाँ आयत 2 चुना जाता है, जो इस उदाहरण में दिखने वाली परतों के क्रम से मेल खाता है। खाली जगह पर क्लिक करने से जानबूझकर चयन हट जाता है। आकृतियों के फ़्रेम और ऐरे की सामग्री कभी नहीं बदलती।

व्यू पॉइंटर और चयन की स्थिति का स्वामी है, जबकि रेंडरर उसे पढ़ता है। Apple सलाह देता है कि निजी स्थिति उस सबसे ऊँचे व्यू में घोषित करें जिसे उसकी ज़रूरत हो, और जब वंशज व्यू को लिखने की पहुँच चाहिए हो, तो बाइंडिंग पास करें। अगर बाद में कोई पैरेंट आइटम संग्रह या चयन का स्वामी बने, तो स्वामित्व वहाँ ले जाएँ और इस व्यू में आवश्यक मान या बाइंडिंग पास करें। Apple का state दस्तावेज़ीकरण।

और जेस्चर जोड़ने से पहले मैपिंग की जाँच कैसे करें?

मॉडल के ज्ञात बिंदुओं से शुरुआत करें और उनकी अपेक्षित लोकल स्थितियाँ निकालें। ये जाँच बिंदु उदाहरण के शुरुआती ज़ूम और पैन का उपयोग करते हैं:

मॉडल बिंदु अपेक्षित कैनवास-लोकल बिंदु अपेक्षित परिणाम
(70, 40) (180, 110) आयत 1
(140, 90) (320, 210) ओवरलैप में आयत 2
(10, 10) (60, 50) कोई आयत नहीं

लोकल स्थितियाँ कैनवास से मापी जाती हैं; टूलबार और बाहरी पैडिंग उस स्पेस के बाहर हैं। दोनों आयतों पर पॉइंटर ले जाएँ, उनके ओवरलैप पर रिलीज़ करें, फिर Zoom out और Pan right आज़माएँ। हर सेटिंग पर हाइलाइट को दिखने वाली ज्योमेट्री का अनुसरण करना चाहिए।

उदाहरण वाला इन्वर्स कैलकुलेशन और 12 फ़ॉरवर्ड/इन्वर्स राउंड-ट्रिप जाँचें Python अंकगणित का उपयोग करके चलाई गईं। शोध परिवेश में Swift कंपाइलर और macOS UI रनटाइम उपलब्ध नहीं थे, इसलिए 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 का representable lifecycle दस्तावेज़ीकरण।

शुद्ध SwiftUI उदाहरण के लिए, इंटरैक्शन को कैनवास से जुड़ा रखें। अगर उसकी जगह AppKit सतह आए, तो उस सतह को इंटरैक्शन क्षेत्र का स्वामी बनाएँ। कोऑर्डिनेट कन्वर्ज़न से उसके मौजूदा स्वामी द्वारा दिए गए इवेंट का रूप बदलना चाहिए; इसके लिए किसी दूसरे प्रतिस्पर्धी चयन हैंडलर को इवेंट की दूसरी प्रति भेजने की ज़रूरत नहीं होनी चाहिए।

बड़े कैनवास के लिए मैपिंग को कैसे विकसित करें?

इस उदाहरण में जानबूझकर एकसमान धनात्मक ज़ूम और ट्रांसलेशन का ही समर्थन है। अगर आप रोटेशन, असमान स्केलिंग, या कोई अलग ड्रॉइंग मूल जोड़ते हैं, तो सरल इन्वर्स की जगह पूरे ड्रॉइंग ट्रांसफ़ॉर्म का इन्वर्स इस्तेमाल करें। इंटरैक्शन से पहले उन मैपिंग को अस्वीकार करें जिन्हें इन्वर्ट नहीं किया जा सकता, और ज्ञात बिंदुओं के साथ फ़ॉरवर्ड/इन्वर्स जाँचें रखें।

आगे चलकर आइटम ड्रैग करने की सुविधा जोड़ने के लिए, जेस्चर शुरू होते समय आइटम और मॉडल-स्पेस में पकड़ का ऑफ़सेट रिकॉर्ड करें। जेस्चर के दौरान उसी आइटम को अपडेट करें; स्पष्ट रूप से तय करें कि ड्रैग के दौरान व्यूपोर्ट मैपिंग स्थिर रहेगी या बदलेगी। हर मूवमेंट पर नया आइटम चुनने के लिए बार-बार हिट टेस्टिंग करने से, आकृतियों के ओवरलैप होने पर इंटरैक्शन का स्वामित्व बदल जाएगा।

एक व्यूपोर्ट कोऑर्डिनेट स्पेस, एक ड्रॉइंग मैपिंग और उससे मेल खाने वाला एक इन्वर्स इस्तेमाल करें। इनके मेल खाने के बाद, ज़ूम और पैन से ज्योमेट्री का रूप बदलने पर भी होवर और चयन स्थिर मॉडल ज्योमेट्री पर काम कर सकते हैं।

स्रोत