Skip to content

Commit 27dbfd9

Browse files
authored
Merge pull request #2 from daangn/feature/jamie/collapsable-json-view
feat: 접을 수 있는 JSON 뷰어 지원 추가
2 parents 50f0999 + 1a8725f commit 27dbfd9

12 files changed

Lines changed: 375 additions & 57 deletions

Demo/Pulse.xcodeproj/project.pbxproj

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@
477477
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
478478
CODE_SIGN_STYLE = Automatic;
479479
INFOPLIST_FILE = Sources/Integrations/Info.plist;
480-
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
480+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
481481
LD_RUNPATH_SEARCH_PATHS = (
482482
"$(inherited)",
483483
"@executable_path/Frameworks",
@@ -499,7 +499,7 @@
499499
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
500500
CODE_SIGN_STYLE = Automatic;
501501
INFOPLIST_FILE = Sources/Integrations/Info.plist;
502-
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
502+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
503503
LD_RUNPATH_SEARCH_PATHS = (
504504
"$(inherited)",
505505
"@executable_path/Frameworks",
@@ -567,7 +567,7 @@
567567
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
568568
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
569569
SWIFT_PACKAGE_NAME = pulse;
570-
TVOS_DEPLOYMENT_TARGET = 15.0;
570+
TVOS_DEPLOYMENT_TARGET = 16.0;
571571
WATCHOS_DEPLOYMENT_TARGET = 8.0;
572572
};
573573
name = Debug;
@@ -618,7 +618,7 @@
618618
SWIFT_COMPILATION_MODE = wholemodule;
619619
SWIFT_OPTIMIZATION_LEVEL = "-O";
620620
SWIFT_PACKAGE_NAME = pulse;
621-
TVOS_DEPLOYMENT_TARGET = 15.0;
621+
TVOS_DEPLOYMENT_TARGET = 16.0;
622622
VALIDATE_PRODUCT = YES;
623623
WATCHOS_DEPLOYMENT_TARGET = 8.0;
624624
};
@@ -697,6 +697,7 @@
697697
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
698698
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
699699
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
700+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
700701
LD_RUNPATH_SEARCH_PATHS = (
701702
"$(inherited)",
702703
"@executable_path/Frameworks",
@@ -740,6 +741,7 @@
740741
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
741742
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
742743
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
744+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
743745
LD_RUNPATH_SEARCH_PATHS = (
744746
"$(inherited)",
745747
"@executable_path/Frameworks",

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import PackageDescription
44
let package = Package(
55
name: "Pulse",
66
platforms: [
7-
.iOS(.v15),
8-
.tvOS(.v15),
7+
.iOS(.v16),
8+
.tvOS(.v16),
99
.macOS(.v13),
1010
.watchOS(.v9),
1111
.visionOS(.v1)

Sources/PulseUI/Features/FileViewer/FileViewer.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ struct FileViewer: View {
3030
ScrollView {
3131
ImageViewer(viewModel: viewModel)
3232
}
33+
case .json(let viewModel):
34+
JSONViewer(viewModel: viewModel)
3335
#if os(iOS) || os(visionOS)
3436
case .pdf(let document):
3537
PDFKitRepresentedView(document: document)

Sources/PulseUI/Features/FileViewer/FileViewerViewModel.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class FileViewerViewModel: ObservableObject {
1616
private let context: FileViewerViewModelContext
1717
var contentType: NetworkLogger.ContentType? { context.contentType }
1818
private let getData: () -> Data
19+
private let settings = UserSettings.shared
1920

2021
private(set) lazy var contents: Contents = render(data: getData())
2122

@@ -27,6 +28,7 @@ final class FileViewerViewModel: ObservableObject {
2728

2829
enum Contents {
2930
case image(ImagePreviewViewModel)
31+
case json(JSONViewerViewModel)
3032
case other(RichTextViewModel)
3133
#if os(iOS) || os(macOS) || os(visionOS)
3234
case pdf(PDFDocument)
@@ -38,6 +40,13 @@ final class FileViewerViewModel: ObservableObject {
3840
return .image(ImagePreviewViewModel(image: image, data: data, context: context))
3941
} else if contentType?.isPDF ?? false, let pdf = makePDF(data: data) {
4042
return pdf
43+
} else if let json = try? JSONSerialization.jsonObject(with: data, options: []) {
44+
if settings.useCollapsibleJSONViewer {
45+
return .json(JSONViewerViewModel(json: json, error: context.error, contentType: contentType))
46+
} else {
47+
let string = TextRenderer().render(json: json, error: context.error)
48+
return .other(RichTextViewModel(string: string, contentType: contentType))
49+
}
4150
} else {
4251
let string = TextRenderer().render(data, contentType: contentType, error: context.error)
4352
return .other(RichTextViewModel(string: string, contentType: contentType))
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// The MIT License (MIT)
2+
//
3+
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
4+
5+
import SwiftUI
6+
import Pulse
7+
import Combine
8+
9+
final class JSONViewerViewModel: ObservableObject {
10+
let json: Any
11+
let error: NetworkLogger.DecodingError?
12+
let contentType: NetworkLogger.ContentType?
13+
14+
@Published private(set) var renderedString: NSAttributedString
15+
16+
private var collapsedPaths: Set<String> = []
17+
private let nodesByPath: [String: JSONContainerNode]
18+
19+
init(json: Any, error: NetworkLogger.DecodingError? = nil, contentType: NetworkLogger.ContentType? = nil) {
20+
self.json = json
21+
self.error = error
22+
self.contentType = contentType
23+
self.nodesByPath = Self.collectNodes(from: json)
24+
self.renderedString = Self.render(json: json, error: error, collapsedPaths: [], nodesByPath: nodesByPath)
25+
}
26+
27+
func toggleNode(_ node: JSONContainerNode) {
28+
guard let path = node.path else { return }
29+
30+
if collapsedPaths.contains(path) {
31+
collapsedPaths.remove(path)
32+
} else {
33+
collapsedPaths.insert(path)
34+
}
35+
36+
updateRendering()
37+
}
38+
39+
func expandAll() {
40+
collapsedPaths.removeAll()
41+
updateRendering()
42+
}
43+
44+
func collapseAll() {
45+
collapsedPaths = Set(nodesByPath.keys)
46+
updateRendering()
47+
}
48+
49+
private func updateRendering() {
50+
renderedString = Self.render(json: json, error: error, collapsedPaths: collapsedPaths, nodesByPath: nodesByPath)
51+
}
52+
53+
private static func render(json: Any, error: NetworkLogger.DecodingError?, collapsedPaths: Set<String>, nodesByPath: [String: JSONContainerNode]) -> NSAttributedString {
54+
TextRendererJSON(json: json, error: error, collapsedPaths: collapsedPaths, nodesByPath: nodesByPath).render()
55+
}
56+
57+
private static func collectNodes(from json: Any) -> [String: JSONContainerNode] {
58+
var nodes: [String: JSONContainerNode] = [:]
59+
60+
func traverse(_ value: Any, path: String) {
61+
switch value {
62+
case let object as [String: Any]:
63+
nodes[path] = JSONContainerNode(kind: .object, json: object, path: path)
64+
for (key, subValue) in object {
65+
traverse(subValue, path: "\(path).\(key)")
66+
}
67+
68+
case let array as [Any]:
69+
nodes[path] = JSONContainerNode(kind: .array, json: array, path: path)
70+
for (index, subValue) in array.enumerated() {
71+
traverse(subValue, path: "\(path)[\(index)]")
72+
}
73+
74+
default:
75+
break
76+
}
77+
}
78+
79+
traverse(json, path: "$")
80+
return nodes
81+
}
82+
}

Sources/PulseUI/Features/Settings/SettingsView-ios.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import SwiftUI
88
import Pulse
99
import UniformTypeIdentifiers
1010

11-
@available(iOS 16, visionOS 1, *)
1211
public struct SettingsView: View {
1312
private let store: LoggerStore
1413
@State private var newHeaderName = ""
@@ -25,6 +24,12 @@ public struct SettingsView: View {
2524
store === RemoteLogger.shared.store {
2625
RemoteLoggerSettingsView(viewModel: .shared)
2726
}
27+
Section("Display") {
28+
Toggle("Collapsible JSON Viewer", isOn: $settings.useCollapsibleJSONViewer)
29+
Text("When enabled, JSON responses will be displayed with collapsible nodes")
30+
.font(.caption)
31+
.foregroundColor(.secondary)
32+
}
2833
Section("Other") {
2934
NavigationLink(destination: StoreDetailsView(source: .store(store)), label: {
3035
Text("Store Info")
@@ -37,7 +42,6 @@ public struct SettingsView: View {
3742
}
3843

3944
#if DEBUG
40-
@available(iOS 16, visionOS 1, *)
4145
struct SettingsView_Previews: PreviewProvider {
4246
static var previews: some View {
4347
NavigationView {

0 commit comments

Comments
 (0)