1+ // The MIT License (MIT)
2+ //
3+ // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
4+
5+ #if os(iOS) || os(visionOS)
6+
7+ import Pulse
8+ import SwiftUI
9+ import UIKit
10+
11+ struct JSONViewer : View {
12+ @ObservedObject var viewModel : JSONViewerViewModel
13+ @State private var shareItems : ShareItems ?
14+
15+ var body : some View {
16+ JSONTextView ( viewModel: viewModel)
17+ . navigationBarItems ( trailing: navigationBarTrailingItems)
18+ . sheet ( item: $shareItems, content: ShareView . init)
19+ }
20+
21+ @ViewBuilder
22+ private var navigationBarTrailingItems : some View {
23+ Menu ( content: {
24+ Button ( action: { viewModel. expandAll ( ) } ) {
25+ Label ( " Expand All " , systemImage: " arrow.down.right.and.arrow.up.left " )
26+ }
27+ Button ( action: { viewModel. collapseAll ( ) } ) {
28+ Label ( " Collapse All " , systemImage: " arrow.up.left.and.arrow.down.right " )
29+ }
30+ Divider ( )
31+ AttributedStringShareMenu ( shareItems: $shareItems) {
32+ viewModel. renderedString
33+ }
34+ } , label: {
35+ Image ( systemName: " ellipsis.circle " )
36+ } )
37+ }
38+ }
39+
40+ struct JSONTextView : UIViewRepresentable {
41+ @ObservedObject var viewModel : JSONViewerViewModel
42+
43+ func makeUIView( context: Context ) -> UITextView {
44+ let textView = UITextView ( usingTextLayoutManager: true )
45+ textView. isEditable = false
46+ textView. isSelectable = true
47+ textView. backgroundColor = . systemBackground
48+ textView. textContainerInset = UIEdgeInsets ( top: 10 , left: 10 , bottom: 10 , right: 10 )
49+ textView. isScrollEnabled = true
50+ textView. alwaysBounceVertical = true
51+
52+ let tapGesture = UITapGestureRecognizer ( target: context. coordinator, action: #selector( Coordinator . handleTap ( _: ) ) )
53+ tapGesture. cancelsTouchesInView = false
54+ textView. addGestureRecognizer ( tapGesture)
55+
56+ return textView
57+ }
58+
59+ func updateUIView( _ textView: UITextView , context: Context ) {
60+ let previousOffset = textView. contentOffset
61+
62+ textView. attributedText = viewModel. renderedString
63+
64+ // Force layout update
65+ if let textLayoutManager = textView. textLayoutManager {
66+ textLayoutManager. ensureLayout ( for: textLayoutManager. documentRange)
67+ }
68+
69+ // Maintain scroll position after toggle
70+ if context. coordinator. shouldPreserveOffset {
71+ textView. setContentOffset ( previousOffset, animated: false )
72+ context. coordinator. shouldPreserveOffset = false
73+ }
74+ }
75+
76+ func makeCoordinator( ) -> Coordinator {
77+ Coordinator ( viewModel: viewModel)
78+ }
79+
80+ class Coordinator : NSObject {
81+ let viewModel : JSONViewerViewModel
82+ var shouldPreserveOffset = false
83+
84+ init ( viewModel: JSONViewerViewModel ) {
85+ self . viewModel = viewModel
86+ }
87+
88+ @objc
89+ func handleTap( _ gesture: UITapGestureRecognizer ) {
90+ guard let textView = gesture. view as? UITextView else { return }
91+
92+ let location = gesture. location ( in: textView)
93+ guard let characterIndex = findCharacterIndex ( at: location, in: textView) else {
94+ // Tapped on empty space, not on text
95+ return
96+ }
97+
98+ if let node = findNode ( at: characterIndex, in: textView. textStorage) {
99+ shouldPreserveOffset = true
100+ viewModel. toggleNode ( node)
101+ }
102+ }
103+
104+ private func findCharacterIndex( at location: CGPoint , in textView: UITextView ) -> Int ? {
105+ var adjustedLocation = location
106+ adjustedLocation. x -= textView. textContainerInset. left
107+ adjustedLocation. y -= textView. textContainerInset. top
108+
109+ guard let textLayoutManager = textView. textLayoutManager,
110+ let fragment = textLayoutManager. textLayoutFragment ( for: adjustedLocation) else {
111+ return nil // No text fragment at this location
112+ }
113+
114+ // Simplified: Get approximate character index
115+ let relativeLocation = CGPoint (
116+ x: adjustedLocation. x - fragment. layoutFragmentFrame. minX,
117+ y: adjustedLocation. y - fragment. layoutFragmentFrame. minY
118+ )
119+
120+ // Find the line fragment
121+ for lineFragment in fragment. textLineFragments {
122+ if lineFragment. typographicBounds. contains ( relativeLocation) {
123+ let index = lineFragment. characterIndex ( for: CGPoint (
124+ x: relativeLocation. x - lineFragment. typographicBounds. minX,
125+ y: relativeLocation. y - lineFragment. typographicBounds. minY
126+ ) )
127+
128+ let startOffset = textView. textLayoutManager? . offset (
129+ from: textView. textLayoutManager!. documentRange. location,
130+ to: fragment. rangeInElement. location
131+ ) ?? 0
132+
133+ return startOffset + index
134+ }
135+ }
136+
137+ return nil // No line fragment at this location
138+ }
139+
140+ private func findNode( at index: Int , in textStorage: NSTextStorage ) -> JSONContainerNode ? {
141+ guard index < textStorage. length else { return nil }
142+
143+ // Only check the exact position and one character before (for the space after arrow)
144+ // The arrow "▶ " or "▼ " is 2 characters, so we check current and previous position
145+ for offset in [ 0 , - 1 ] {
146+ let checkIndex = index + offset
147+ guard checkIndex >= 0 && checkIndex < textStorage. length else { continue }
148+
149+ var effectiveRange = NSRange ( )
150+ if let node = textStorage. attribute ( . node, at: checkIndex, effectiveRange: & effectiveRange) as? JSONContainerNode {
151+ // Only return the node if we're within the arrow's range
152+ if NSLocationInRange ( index, effectiveRange) {
153+ return node
154+ }
155+ }
156+ }
157+
158+ return nil
159+ }
160+ }
161+ }
162+
163+ #elseif os(tvOS) || os(watchOS)
164+
165+ import Pulse
166+ import SwiftUI
167+
168+ struct JSONViewer : View {
169+ @ObservedObject var viewModel : JSONViewerViewModel
170+
171+ var body : some View {
172+ ScrollView {
173+ Text ( AttributedString ( viewModel. renderedString) )
174+ . font ( . system( . body, design: . monospaced) )
175+ . padding ( )
176+ }
177+ }
178+ }
179+
180+ #endif
0 commit comments