SwiftUI में लिपटे NSTextView में खोज हाइलाइट अपडेट करते रहने के लिए, क्वेरी या टेक्स्ट बदलने पर TextKit 2 रेंडरिंग एट्रिब्यूट अमान्य करें, रेंडरिंग वैलिडेटर में मौजूदा सामग्री से हाइलाइट फिर बनाएँ और री-डिस्प्ले का अनुरोध करें।

यह वॉकथ्रू Tile A और Circle B जैसे कृत्रिम लेबल के लिए एक छोटा Mac एडिटर बनाता है। इसका ठोस काम है उन लेबलों को हाइलाइट करना जो खोज फ़ील्ड बदलने या उसी ऐप में दस्तावेज़ संपादित करने पर मेल खाते हैं।

हाइलाइट बदलने पर क्या अपडेट होना चाहिए?

दो स्वतंत्र इनपुट हैं: दस्तावेज़ के अक्षर और हाइलाइट क्वेरी। दस्तावेज़ संपादित करने से टेक्स्ट सामग्री बदलती है। SwiftUI खोज फ़ील्ड बदलने से ऐप की स्थिति बदलती है, जबकि टेक्स्ट सिस्टम में शायद कुछ भी न बदले। आपके रैपर को दोनों घटनाएँ हाइलाइटर तक पहुँचानी चाहिए।

Apple, लेआउट फ़्रैगमेंट के लिए रेंडरिंग एट्रिब्यूट देने वाले कॉलबैक के रूप में renderingAttributesValidator उपलब्ध कराता है। यह टेक्स्ट रेंज में उन एट्रिब्यूट को अमान्य करने के लिए invalidateRenderingAttributes(for:) भी उपलब्ध कराता है। ये API मिलकर ऐसी डिज़ाइन का समर्थन करते हैं जिसमें क्वेरी को मौजूदा स्थिति के रूप में रखा जाता है और रेंडरिंग वैलिडेशन की आवश्यकता होने पर हाइलाइट फिर बनाए जाते हैं। रेंडरिंग एट्रिब्यूट वैलिडेटर, रेंडरिंग एट्रिब्यूट अमान्य करें।

री-डिस्प्ले एक अलग विषय है। needsDisplay सेट करने से अगले इवेंट-लूप पास में व्यू ड्रॉ करने के लिए चिह्नित होता है। यह अपने-आप यह नहीं बताता कि कौन-सा हाइलाइट नियम बदला है या नए मेल की गणना नहीं करता। इसलिए नीचे का कार्यान्वयन रेंडरिंग एट्रिब्यूट अमान्य करता है और री-डिस्प्ले का अनुरोध करता है। NSView needsDisplay।

घटना अपडेट की जाने वाली स्थिति कार्रवाई
खोज फ़ील्ड बदलता है टेक्स्ट व्यू की मौजूदा क्वेरी रेंडरिंग एट्रिब्यूट अमान्य करें और री-डिस्प्ले का अनुरोध करें
दस्तावेज़ का टेक्स्ट बदलता है AppKit में संपादित टेक्स्ट पहले से मौजूद है टेक्स्ट डेलीगेट के ज़रिए हाइलाइट अमान्य करें
किसी फ़्रैगमेंट को वैलिडेशन चाहिए उसके अस्थायी हाइलाइट एट्रिब्यूट पिछला बैकग्राउंड हटाएँ और मौजूदा मेल लागू करें
क्वेरी खाली हो जाती है मौजूदा क्वेरी नए हाइलाइट जोड़े बिना पुराने हटाएँ

टेक्स्ट व्यू को TextKit 2 पर रखें

इस उदाहरण के लिए macOS 13 या बाद के संस्करण का डिप्लॉयमेंट टार्गेट इस्तेमाल करें और टेक्स्ट व्यू को NSTextView(usingTextLayoutManager: true) से स्पष्ट रूप से शुरू करें। Apple ने अपने WWDC22 सत्र में बताए गए बदलाव के साथ इंजन चुनने वाले सुविधा कंस्ट्रक्टर पेश किए। यहाँ इस्तेमाल किए गए मुख्य AppKit रेंडरिंग-एट्रिब्यूट API macOS 12 से उपलब्ध बताए गए हैं। TextKit और टेक्स्ट व्यू में नया क्या है, NSTextLayoutManager।

सक्रिय इंजन की जाँच के लिए textView.textLayoutManager देखें। layoutManager तक पहुँचना, टेक्स्ट कंटेनर के ज़रिए भी, TextKit 1 संगतता मोड चालू कर सकता है। Apple बताता है कि मौजूदा टेक्स्ट व्यू के लिए यह बदलाव एक ही दिशा में होता है। TextKit 2 के पथ में पुराने लेआउट-मैनेजर क्वेरी न रखें। TextKit संगतता और आधुनिकीकरण।

दूसरी सीमा SwiftUI का लेआउट पर नियंत्रण है। Apple प्रतिनिधित्व किए गए व्यू के frame या bounds को सीधे बदलने से मना करता है। यहाँ प्रतिनिधित्व किया गया रूट एक NSScrollView है; शुरुआती फ़्रेम का निर्धारण उसके AppKit दस्तावेज़ सबव्यू का काम है। स्क्रॉल व्यू के बाहरी आकार को SwiftUI नियंत्रित करता है। NSViewRepresentable।

पूरा संपादन योग्य उदाहरण बनाएँ

Xcode में macOS SwiftUI ऐप बनाएँ, उसका डिप्लॉयमेंट टार्गेट 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 ऑफ़सेट की पुष्टि अलग नियतात्मक जाँचों से की गई; वे जाँचें GUI व्यवहार सिद्ध नहीं करतीं।

रैपर दो अपडेट पथ क्यों इस्तेमाल करता है

updateNSView को SwiftUI की नई स्थिति मिलती है और यह बदली हुई क्वेरी मौजूदा टेक्स्ट व्यू में कॉपी करता है। वैलिडेटर हर बार चलने पर उस संग्रहीत क्वेरी को पढ़ता है। इसका वीक रेफ़रेंस AppKit व्यू की ओर होता है, न कि ऐसे शुरुआती representable मान को कैप्चर करता है जिसमें पुरानी क्वेरी हो सकती है। Apple representable के अपडेट मेथड को नई SwiftUI जानकारी लागू करने की जगह बताता है। NSViewRepresentable जीवनचक्र।

दस्तावेज़ में टाइपिंग कोऑर्डिनेटर पथ से होती है। Apple textDidChange(_:) को ऐसा डेलीगेट नोटिफ़िकेशन बताता है जो अक्षरों या फ़ॉर्मैटिंग एट्रिब्यूट में बदलाव की सूचना देता है। कोऑर्डिनेटर तुरंत हाइलाइट रीफ़्रेश करता है; SwiftUI अपडेट चालू करने के लिए उसे दस्तावेज़ बाइंडिंग की आवश्यकता नहीं है। textDidChange(_:)।

शुरुआती नमूना टेक्स्ट केवल निर्माण के दौरान असाइन किया जाता है। बाद के क्वेरी अपडेट दस्तावेज़ को नहीं छूते। अगर आप बाद में दस्तावेज़ बाइंडिंग जोड़ें, तो आने वाली सामग्री बदलने से पहले उसकी तुलना करें और तय करें कि संपादन किस ओर के नियंत्रण में होंगे। हर अपडेट में नमूना टेक्स्ट दोबारा असाइन करने से उपयोगकर्ता का दस्तावेज़ मिट जाएगा।

खाली क्वेरी की जाँच से पहले हाइलाइट हटाना क्यों ज़रूरी है

हर वैलिडेशन में हाइलाइटर, मेल ढूँढ़ने से पहले फ़्रैगमेंट से अपना पिछला .backgroundColor एट्रिब्यूट हटा देता है। इसलिए खाली क्वेरी होने पर भी सफ़ाई होती है। इससे एक आम कार्यान्वयन त्रुटि दूर होती है: खोज तब तक काम करती है जब तक फ़ील्ड खाली नहीं किया जाता, और उसके बाद हाइलाइट का आख़िरी समूह दिखाई देता रहता है।

यह उदाहरण बैकग्राउंड एट्रिब्यूट तक बदलाव सीमित रखने के लिए दस्तावेज़ीकृत, प्रति-की हटाने और जोड़ने वाले API इस्तेमाल करता है। यह मानता है कि रेंडरिंग बैकग्राउंड इस हाइलाइटर के नियंत्रण में हैं; अगर कोई और सुविधा भी उन्हें देती है, तो उन नियमों को एक रेंडरिंग नीति में मिलाएँ। रेंडरिंग एट्रिब्यूट हटाएँ, रेंडरिंग एट्रिब्यूट जोड़ें।

मिलानों को वैलिडेट किए जा रहे फ़्रैगमेंट से भी काटकर सीमित किया जाता है। Apple rangeInElement को दस्तावेज़ के आरंभ के सापेक्ष परिभाषित करता है, और intersection(_:) साझा टेक्स्ट रेंज देता है। इससे कोई मेल वर्तमान फ़्रैगमेंट में आने वाले हिस्से का ही योगदान देता है। फ़्रैगमेंट रेंज, टेक्स्ट रेंज का प्रतिच्छेदन।

मेल के ऑफ़सेट को मौजूदा दस्तावेज़ के अनुरूप रखें

यह उदाहरण NSString स्नैपशॉट में खोज करता है और उससे मिला NSRange इस्तेमाल करता है। Apple बताता है कि NSString की लंबाई, इंडेक्स और रेंज UTF-16 कोड यूनिट में होते हैं। Swift भी utf16 के ज़रिए स्ट्रिंग का UTF-16 निरूपण देता है; Swift कैरेक्टर की गिनती इन ऑफ़सेट का विकल्प नहीं है। NSString इंडेक्सिंग, String.utf16।

एक उदाहरणात्मक जाँच के लिए नमूना दस्तावेज़ की शुरुआत में तटस्थ प्रतीक और स्पेस 🔷 जोड़ें। पहला Tile, UTF-16 ऑफ़सेट 3 पर शुरू होता है और उसकी लंबाई 4 है। इसका दूसरा उदाहरण ऑफ़सेट 19 पर शुरू होता है। शुरुआत में X डालने पर ये स्थान 5 और 21 हो जाते हैं। ये गणना के उदाहरण हैं, AppKit रन से लिए गए अवलोकन नहीं।

हर मेल को मौजूदा कंटेंट मैनेजर के ज़रिए बदलें: दस्तावेज़ के आरंभ से मेल के आरंभ तक ऑफ़सेट लें, फिर वहाँ से मेल के अंत तक। टेक्स्ट लोकेशन स्वयं पूर्णांक इंडेक्स से न बनाएँ और दस्तावेज़ संपादित होने के बाद पुराने मेल लोकेशन अपने पास न रखें। Apple इस बदलाव को कंटेंट मैनेजर के ज़रिए दिखाता है; उसका लोकेशन API सीमा से बाहर के ऑफ़सेट के लिए nil लौटा सकता है। रेंज बदलने का मार्गदर्शन, टेक्स्ट कंटेंट स्टोरेज लोकेशन।

खोज लूप, लौटाए गए वास्तविक मेल के अंत का इस्तेमाल करके आगे बढ़ता है। Apple बताता है कि छोटे रेंज में खोज करने पर भी खोज परिणाम स्ट्रिंग के आरंभ के सापेक्ष होते हैं। उदाहरण में खाली क्वेरी की जाँच और आगे की ओर बढ़ना, दोनों मिलकर लूप को रुकने से बचाते हैं। सामान्य उपयोगकर्ता-केंद्रित Find सुविधा के लिए उपयुक्त स्थानीयकृत मेल व्यवहार चुनें; Apple उपयोगकर्ताओं को दिखाए जाने वाले टेक्स्ट के लिए स्थानीयकृत मानक खोज की सलाह देता है। NSString रेंज खोज।

पुराने हाइलाइट का उपयोगी क्रम में पता लगाएँ

इस क्रम से पता करें कि समस्या स्थिति अपडेट न होने की है या रेंडरिंग अथवा रेंज की। ये उदाहरण के लिए अपेक्षित जाँचें हैं, रिपोर्ट किए गए परीक्षण परिणाम नहीं।

  1. Tile से शुरू करें। दोनों Tile लेबलों पर बैकग्राउंड हाइलाइट होना चाहिए।
  2. क्वेरी को Circle में बदलें। पुराने बैकग्राउंड हट जाने चाहिए और Circle लेबल हाइलाइट होना चाहिए।
  3. क्वेरी खाली करें। सभी खोज बैकग्राउंड हट जाने चाहिए।
  4. Tile को फिर से रखें, फिर एक मेल खाते लेबल को Square में संपादित करें। संपादित लेबल अब मेल नहीं खाना चाहिए।
  5. पहले लेबल से पहले 🔷 डालें। मेल टेक्स्ट के साथ आगे बढ़ने चाहिए, पुराने पूर्णांक ऑफ़सेट पर नहीं रहने चाहिए।
  6. स्क्रॉल करने के लिए पर्याप्त कृत्रिम लेबल पंक्तियाँ जोड़ें। क्वेरी बदलें, फिर दस्तावेज़ में स्क्रॉल करके नए दिखाई देने वाले फ़्रैगमेंट जाँचें।

अगर क्वेरी बदलती है लेकिन कुछ अपडेट नहीं होता, तो पहले updateNSView, संग्रहीत क्वेरी और अमान्य करने वाले कॉल की जाँच करें। अगर क्वेरी बदलने पर काम होता है लेकिन दस्तावेज़ संपादन पर नहीं, तो डेलीगेट असाइनमेंट और textDidChange(_:) देखें। अगर पुराने मेल रंगीन बने रहते हैं, तो जाँचें कि खाली क्वेरी के लिए हर शुरुआती वापसी से पहले सफ़ाई चलती है।

अगर textLayoutManager, nil बन जाता है, तो और ड्रॉ कॉल जोड़ने से पहले संगतता मोड की जाँच करें। Apple NSTextView के फ़ॉलबैक का पता लगाने के लिए लेआउट-मैनेजर स्विच नोटिफ़िकेशन देता है। इस प्रदर्शन के लिए TextKit 2 आवश्यक है और इसमें TextKit 1 हाइलाइटर लागू नहीं किया गया है। संगतता-मोड निदान।

बड़े दस्तावेज़ों के लिए कार्यान्वयन ढालें

यह कार्यान्वयन ऐसे अपडेट पथ को प्राथमिकता देता है जिसकी जाँच करना आसान हो: यह दस्तावेज़ के रेंडरिंग एट्रिब्यूट अमान्य करता है और हर फ़्रैगमेंट वैलिडेशन के दौरान मौजूदा स्ट्रिंग को स्कैन करता है। इससे काम दोहराया जाता है, इसलिए यह छोटे प्रदर्शन के लिए उपयुक्त है, किसी बड़े दस्तावेज़ के ऐसे कार्यभार के लिए नहीं जिसका मापन न किया गया हो।

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

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

एडिटर के बढ़ने के साथ अपडेट क्रम स्पष्ट रखें: मौजूदा स्थिति प्राप्त करें, प्रभावित रेंडरिंग एट्रिब्यूट अमान्य करें, पुराने हाइलाइट मान हटाएँ और मौजूदा टेक्स्ट के आधार पर मेल फिर बनाएँ। क्वेरी बदलना, दस्तावेज़ संपादित करना, क्वेरी साफ़ करना और स्क्रॉल करना—इन सबकी अलग-अलग जाँच करें, ताकि हर त्रुटि कार्यान्वयन के किसी विशिष्ट हिस्से की ओर संकेत करे।

स्रोत