From df9782db7f550662101b200beb9d57f631cc427c Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 22:51:12 +0900 Subject: [PATCH 01/23] feat(core): make alpha/cornerRadius optional and add text row SwiftUI accessibility elements cannot provide alpha or cornerRadius and carry their content as accessibilityLabel, so ViewInspection needs optional fields and a conditional text row. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/InfoRowBuilder.swift | 7 ++++-- Sources/ViewMonitor/Core/ViewInspection.swift | 11 +++++---- Sources/ViewMonitor/Core/ViewInspector.swift | 1 + .../InfoRowBuilderTests.swift | 23 +++++++++++++++++-- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Sources/ViewMonitor/Core/InfoRowBuilder.swift b/Sources/ViewMonitor/Core/InfoRowBuilder.swift index bd96ce6..1db0e03 100644 --- a/Sources/ViewMonitor/Core/InfoRowBuilder.swift +++ b/Sources/ViewMonitor/Core/InfoRowBuilder.swift @@ -32,9 +32,12 @@ enum InfoRowBuilder { InfoRow(title: "width", value: inspection.map { format($0.size.width) } ?? "None"), InfoRow(title: "height", value: inspection.map { format($0.size.height) } ?? "None"), InfoRow(title: "background", value: inspection.map { hex($0.backgroundColorHex) } ?? "None"), - InfoRow(title: "alpha", value: inspection.map { format($0.alpha) } ?? "None"), - InfoRow(title: "cornerRadius", value: inspection.map { format($0.cornerRadius) } ?? "None") + InfoRow(title: "alpha", value: (inspection?.alpha).map(format) ?? "None"), + InfoRow(title: "cornerRadius", value: (inspection?.cornerRadius).map(format) ?? "None") ] + if let text = inspection?.text { + rows.append(InfoRow(title: "text", value: text)) + } if let font = inspection?.font { rows.append(InfoRow(title: "font", value: font.familyName)) rows.append(InfoRow(title: "fontSize", value: format(font.pointSize))) diff --git a/Sources/ViewMonitor/Core/ViewInspection.swift b/Sources/ViewMonitor/Core/ViewInspection.swift index f608f4d..938e8d7 100644 --- a/Sources/ViewMonitor/Core/ViewInspection.swift +++ b/Sources/ViewMonitor/Core/ViewInspection.swift @@ -21,11 +21,14 @@ struct ViewInspection: Equatable { /// 背景色(`RRGGBB`)。取得できない場合は nil。 let backgroundColorHex: String? - /// 不透明度。 - let alpha: CGFloat + /// 不透明度。SwiftUI のアクセシビリティ要素では取得できないため nil。 + let alpha: CGFloat? - /// 角丸の半径。 - let cornerRadius: CGFloat + /// 角丸の半径。SwiftUI のアクセシビリティ要素では取得できないため nil。 + let cornerRadius: CGFloat? + + /// テキスト内容。SwiftUI 要素の accessibilityLabel。UIKit ビューでは nil。 + let text: String? /// フォント情報。テキストを持つビュー(UILabel / UIButton)以外は nil。 let font: FontInfo? diff --git a/Sources/ViewMonitor/Core/ViewInspector.swift b/Sources/ViewMonitor/Core/ViewInspector.swift index 82c9f1e..b1f5e1f 100644 --- a/Sources/ViewMonitor/Core/ViewInspector.swift +++ b/Sources/ViewMonitor/Core/ViewInspector.swift @@ -19,6 +19,7 @@ enum ViewInspector { backgroundColorHex: view.backgroundColor?.monitorHexString, alpha: view.alpha, cornerRadius: view.layer.cornerRadius, + text: nil, font: fontInfo(of: view) ) } diff --git a/Tests/ViewMonitorTests/InfoRowBuilderTests.swift b/Tests/ViewMonitorTests/InfoRowBuilderTests.swift index 0fc1782..0c8d7a6 100644 --- a/Tests/ViewMonitorTests/InfoRowBuilderTests.swift +++ b/Tests/ViewMonitorTests/InfoRowBuilderTests.swift @@ -12,8 +12,9 @@ struct InfoRowBuilderTests { width: CGFloat = 343, height: CGFloat = 20, backgroundColorHex: String? = nil, - alpha: CGFloat = 1.0, - cornerRadius: CGFloat = 0.0, + alpha: CGFloat? = 1.0, + cornerRadius: CGFloat? = 0.0, + text: String? = nil, font: ViewInspection.FontInfo? = nil ) -> ViewInspection { ViewInspection( @@ -23,6 +24,7 @@ struct InfoRowBuilderTests { backgroundColorHex: backgroundColorHex, alpha: alpha, cornerRadius: cornerRadius, + text: text, font: font ) } @@ -148,4 +150,21 @@ struct InfoRowBuilderTests { #expect(rows[10].title == "fontColor") #expect(rows[11] == InfoRow(title: "vs", value: "UILabel")) } + + @Test("alpha と cornerRadius が nil なら None と表示する") + func showsNoneForMissingAlphaAndCornerRadius() { + // SwiftUI のアクセシビリティ要素では取得できない項目。 + let rows = InfoRowBuilder.rows(from: makeInspection(alpha: nil, cornerRadius: nil)) + + #expect(rows[6] == InfoRow(title: "alpha", value: "None")) + #expect(rows[7] == InfoRow(title: "cornerRadius", value: "None")) + } + + @Test("text がある計測結果は共通8行の直後に text 行が入る") + func appendsTextRow() { + let rows = InfoRowBuilder.rows(from: makeInspection(text: "Hello")) + + #expect(rows.count == 9) + #expect(rows[8] == InfoRow(title: "text", value: "Hello")) + } } From dfdd77c12957e9a25fc3e9555dad2cc2694fd269 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 22:56:58 +0900 Subject: [PATCH 02/23] feat(core): add MeasurementTarget and accessibility element scanner SwiftUI's Text/Image/Button never create UIKit views, so the scanner walks the accessibility tree that SwiftUI publishes for VoiceOver (public API only) and classifies elements by their traits. Co-Authored-By: Claude Fable 5 --- .../Core/AccessibilityElementScanner.swift | 48 +++++++++++ .../ViewMonitor/Core/MeasurementTarget.swift | 17 ++++ .../AccessibilityElementScannerTests.swift | 84 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 Sources/ViewMonitor/Core/AccessibilityElementScanner.swift create mode 100644 Sources/ViewMonitor/Core/MeasurementTarget.swift create mode 100644 Tests/ViewMonitorTests/AccessibilityElementScannerTests.swift diff --git a/Sources/ViewMonitor/Core/AccessibilityElementScanner.swift b/Sources/ViewMonitor/Core/AccessibilityElementScanner.swift new file mode 100644 index 0000000..667f7c3 --- /dev/null +++ b/Sources/ViewMonitor/Core/AccessibilityElementScanner.swift @@ -0,0 +1,48 @@ +import UIKit + +/// ホスティングビューのアクセシビリティ要素ツリーを走査して SwiftUI 要素を集める。 +/// SwiftUI の Text / Image / Button は UIKit ビューを生成しないため、 +/// VoiceOver 向けに公開されるアクセシビリティ要素を計測対象として使う。 +struct AccessibilityElementScanner { + + /// `hostingView` 配下のアクセシビリティ要素から計測対象を集める。 + @MainActor + func targets(in hostingView: UIView) -> [AccessibilityElementInfo] { + collect(from: hostingView.accessibilityElements ?? [], hostingView: hostingView) + } + + @MainActor + private func collect(from elements: [Any], hostingView: UIView) -> [AccessibilityElementInfo] { + var result: [AccessibilityElementInfo] = [] + for case let element as NSObject in elements { + // UIView は subview 走査が拾うため、二重検出を防いでスキップする。 + if element is UIView { + continue + } + if element.isAccessibilityElement, let kind = Self.kind(of: element.accessibilityTraits) { + result.append( + AccessibilityElementInfo(element: element, hostingView: hostingView, kind: kind) + ) + } + result.append( + contentsOf: collect(from: element.accessibilityElements ?? [], hostingView: hostingView) + ) + } + return result + } + + /// traits から種別表示名を決める。対象外の traits は nil。 + /// 複合 traits(ボタン内テキスト等)は button > image > staticText の優先順。 + static func kind(of traits: UIAccessibilityTraits) -> String? { + if traits.contains(.button) { + return "Button" + } + if traits.contains(.image) { + return "Image" + } + if traits.contains(.staticText) { + return "Text" + } + return nil + } +} diff --git a/Sources/ViewMonitor/Core/MeasurementTarget.swift b/Sources/ViewMonitor/Core/MeasurementTarget.swift new file mode 100644 index 0000000..26c363c --- /dev/null +++ b/Sources/ViewMonitor/Core/MeasurementTarget.swift @@ -0,0 +1,17 @@ +import UIKit + +/// 計測対象。UIKit ビューまたは SwiftUI のアクセシビリティ要素。 +enum MeasurementTarget { + case uiKitView(UIView) + case accessibilityElement(AccessibilityElementInfo) +} + +/// SwiftUI 要素1つ分の参照。 +struct AccessibilityElementInfo { + /// SwiftUI が生成するアクセシビリティノード。画面遷移で破棄されたら nil。 + weak var element: NSObject? + /// この要素を含むホスティングビュー。座標変換と window 同一性判定に使う。 + weak var hostingView: UIView? + /// traits 由来の種別表示名("Text" / "Image" / "Button")。 + let kind: String +} diff --git a/Tests/ViewMonitorTests/AccessibilityElementScannerTests.swift b/Tests/ViewMonitorTests/AccessibilityElementScannerTests.swift new file mode 100644 index 0000000..d017634 --- /dev/null +++ b/Tests/ViewMonitorTests/AccessibilityElementScannerTests.swift @@ -0,0 +1,84 @@ +import Testing +import UIKit +@testable import ViewMonitor + +@Suite("AccessibilityElementScanner") +@MainActor +struct AccessibilityElementScannerTests { + + private let scanner = AccessibilityElementScanner() + + private func makeElement( + in container: UIView, + traits: UIAccessibilityTraits, + label: String? = nil + ) -> UIAccessibilityElement { + let element = UIAccessibilityElement(accessibilityContainer: container) + element.isAccessibilityElement = true + element.accessibilityTraits = traits + element.accessibilityLabel = label + return element + } + + @Test("staticText / image / button の要素を種別名付きで集める") + func collectsElementsWithKinds() { + let host = UIView() + let text = makeElement(in: host, traits: .staticText, label: "Hello") + let image = makeElement(in: host, traits: .image) + let button = makeElement(in: host, traits: .button) + host.accessibilityElements = [text, image, button] + + let targets = scanner.targets(in: host) + + #expect(targets.map(\.kind) == ["Text", "Image", "Button"]) + #expect(targets[0].element === text) + #expect(targets[0].hostingView === host) + } + + @Test("複合 traits は button > image > staticText の優先順で1つに決める") + func prefersButtonOverText() { + let host = UIView() + let element = makeElement(in: host, traits: [.button, .staticText], label: "Tap") + host.accessibilityElements = [element] + + #expect(scanner.targets(in: host).map(\.kind) == ["Button"]) + } + + @Test("対象外 traits のみの要素は集めない") + func ignoresUnrelatedTraits() { + let host = UIView() + host.accessibilityElements = [makeElement(in: host, traits: .adjustable)] + + #expect(scanner.targets(in: host).isEmpty) + } + + @Test("入れ子のコンテナ要素を再帰的に走査する") + func collectsNestedElements() { + let host = UIView() + let container = UIAccessibilityElement(accessibilityContainer: host) + container.isAccessibilityElement = false + let leaf = makeElement(in: host, traits: .staticText, label: "Nested") + container.accessibilityElements = [leaf] + host.accessibilityElements = [container] + + let targets = scanner.targets(in: host) + + #expect(targets.count == 1) + #expect(targets[0].element === leaf) + } + + @Test("UIView の要素はスキップする(subview 走査との二重検出を防ぐ)") + func skipsUIViewElements() { + let host = UIView() + let label = UILabel() + label.accessibilityTraits = .staticText + host.accessibilityElements = [label] + + #expect(scanner.targets(in: host).isEmpty) + } + + @Test("accessibilityElements が nil なら空を返す") + func returnsEmptyForNilElements() { + #expect(scanner.targets(in: UIView()).isEmpty) + } +} From 4ea059a0b0304b6540dc12e85319306dd72bf46e Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 23:01:44 +0900 Subject: [PATCH 03/23] feat(core): inspect accessibility elements in window coordinates Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/ViewInspector.swift | 21 +++++++++++++ .../ViewMonitorTests/ViewInspectorTests.swift | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Sources/ViewMonitor/Core/ViewInspector.swift b/Sources/ViewMonitor/Core/ViewInspector.swift index b1f5e1f..9b8f671 100644 --- a/Sources/ViewMonitor/Core/ViewInspector.swift +++ b/Sources/ViewMonitor/Core/ViewInspector.swift @@ -24,6 +24,27 @@ enum ViewInspector { ) } + /// SwiftUI のアクセシビリティ要素を `window` の座標系で計測する。 + /// `accessibilityFrame` はスクリーン座標のため window 座標へ変換する。 + /// `window` が nil の場合は accessibilityFrame をそのまま使う。 + @MainActor + static func inspect(element: NSObject, kind: String, in window: UIWindow?) -> ViewInspection { + let screenFrame = element.accessibilityFrame + let frame = window.map { + $0.coordinateSpace.convert(screenFrame, from: $0.screen.coordinateSpace) + } ?? screenFrame + return ViewInspection( + className: kind, + frameInWindow: frame, + size: frame.size, + backgroundColorHex: nil, + alpha: nil, + cornerRadius: nil, + text: element.accessibilityLabel, + font: nil + ) + } + @MainActor private static func fontInfo(of view: UIView) -> ViewInspection.FontInfo? { guard let label = textLabel(of: view), let font = label.font else { diff --git a/Tests/ViewMonitorTests/ViewInspectorTests.swift b/Tests/ViewMonitorTests/ViewInspectorTests.swift index 9bcbd19..a37b0d3 100644 --- a/Tests/ViewMonitorTests/ViewInspectorTests.swift +++ b/Tests/ViewMonitorTests/ViewInspectorTests.swift @@ -126,4 +126,35 @@ struct ViewInspectorTests { #expect(font.pointSize == 15) #expect(font.familyName == UIFont.systemFont(ofSize: 15).familyName) } + + @Test("アクセシビリティ要素をウィンドウ座標で計測する") + func inspectsAccessibilityElement() { + // accessibilityFrame はスクリーン座標。原点 (10, 20) の window では + // スクリーン (30, 40) が window 座標 (20, 20) になる。 + let window = UIWindow(frame: CGRect(x: 10, y: 20, width: 320, height: 480)) + let element = UIAccessibilityElement(accessibilityContainer: window) + element.accessibilityLabel = "Hello" + element.accessibilityFrame = CGRect(x: 30, y: 40, width: 120, height: 20) + + let inspection = ViewInspector.inspect(element: element, kind: "Text", in: window) + + #expect(inspection.className == "Text") + #expect(inspection.frameInWindow == CGRect(x: 20, y: 20, width: 120, height: 20)) + #expect(inspection.size == CGSize(width: 120, height: 20)) + #expect(inspection.text == "Hello") + #expect(inspection.backgroundColorHex == nil) + #expect(inspection.alpha == nil) + #expect(inspection.cornerRadius == nil) + #expect(inspection.font == nil) + } + + @Test("window が nil なら accessibilityFrame をそのまま使う") + func fallsBackToAccessibilityFrameWithoutWindow() { + let element = UIAccessibilityElement(accessibilityContainer: UIView()) + element.accessibilityFrame = CGRect(x: 30, y: 40, width: 120, height: 20) + + let inspection = ViewInspector.inspect(element: element, kind: "Button", in: nil) + + #expect(inspection.frameInWindow == CGRect(x: 30, y: 40, width: 120, height: 20)) + } } From 0f9822fff960c0eea1ebea4a84ef008f3872baca Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 23:06:51 +0900 Subject: [PATCH 04/23] feat(core): merge accessibility elements into hierarchy scan Co-Authored-By: Claude Fable 5 --- .../Core/ViewHierarchyScanner.swift | 47 +++++++++++++--- .../ViewHierarchyScannerTests.swift | 53 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift index 33e1bb2..41c3511 100644 --- a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift +++ b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift @@ -1,31 +1,64 @@ import UIKit -/// ビュー階層を走査して計測対象のビューを集める。 +/// ビュー階層を走査して計測対象を集める。 +/// UIKit ビューに加え、ホスティングビュー配下は SwiftUI のアクセシビリティ要素を集める。 struct ViewHierarchyScanner { let configuration: MonitorConfiguration - init(configuration: MonitorConfiguration = .default) { + private let accessibilityScanner = AccessibilityElementScanner() + + /// ホスティングビュー判定。実体はクラス名照合のみだが、テストで差し替えられるよう注入可能にする。 + private let isHostingView: @MainActor (UIView) -> Bool + + init( + configuration: MonitorConfiguration = .default, + isHostingView: @escaping @MainActor (UIView) -> Bool = { ViewHierarchyScanner.isDefaultHostingView($0) } + ) { self.configuration = configuration + self.isHostingView = isHostingView + } + + /// SwiftUI のホスティングビューかどうか。 + /// `_UIHostingView` はジェネリッククラスで `NSStringFromClass` がマングル名を + /// 返すため、`String(describing:)` のプレフィックスで判定する。プライベート API は呼ばない。 + static func isDefaultHostingView(_ view: UIView) -> Bool { + String(describing: type(of: view)).hasPrefix("_UIHostingView") } - /// `root` を含む階層から計測対象のビューを深さ優先で集める。 + /// `root` を含む階層から計測対象を深さ優先で集める。 /// 除外対象のビューに達した時点で、その子孫は走査しない。 @MainActor - func targets(in root: UIView) -> [UIView] { + func measurementTargets(in root: UIView) -> [MeasurementTarget] { guard !isRejected(root) else { return [] } - var result: [UIView] = [] + var result: [MeasurementTarget] = [] if isTarget(root) { - result.append(root) + result.append(.uiKitView(root)) + } + if isHostingView(root) { + result.append( + contentsOf: accessibilityScanner.targets(in: root).map { .accessibilityElement($0) } + ) } for subview in root.subviews { - result.append(contentsOf: targets(in: subview)) + result.append(contentsOf: measurementTargets(in: subview)) } return result } + /// `root` を含む階層から計測対象の UIKit ビューだけを集める。 + @MainActor + func targets(in root: UIView) -> [UIView] { + measurementTargets(in: root).compactMap { target in + if case .uiKitView(let view) = target { + return view + } + return nil + } + } + /// 計測対象かどうか。 @MainActor func isTarget(_ view: UIView) -> Bool { diff --git a/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift b/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift index d6b4cef..5a25698 100644 --- a/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift +++ b/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift @@ -1,5 +1,6 @@ import Testing import UIKit +import SwiftUI @testable import ViewMonitor /// 追加対象クラスの指定を検証するためのテスト専用ビュー。 @@ -125,4 +126,56 @@ struct ViewHierarchyScannerTests { #expect(ViewHierarchyScanner.className(of: ScannerProbeView()) == "ScannerProbeView") #expect(ViewHierarchyScanner.className(of: UILabel()) == "UILabel") } + + @Test("ホスティングビュー配下のアクセシビリティ要素を統合して集める") + func collectsAccessibilityTargetsFromHostingView() { + let root = UIView() + let label = UILabel() + let hostingProbe = UIView() + let element = UIAccessibilityElement(accessibilityContainer: hostingProbe) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + hostingProbe.accessibilityElements = [element] + root.addSubview(label) + root.addSubview(hostingProbe) + let scanner = ViewHierarchyScanner(isHostingView: { $0 === hostingProbe }) + + let targets = scanner.measurementTargets(in: root) + + let views = targets.compactMap { target -> UIView? in + if case .uiKitView(let view) = target { return view } + return nil + } + let elements = targets.compactMap { target -> AccessibilityElementInfo? in + if case .accessibilityElement(let info) = target { return info } + return nil + } + #expect(views == [label]) + #expect(elements.count == 1) + #expect(elements[0].element === element) + #expect(elements[0].hostingView === hostingProbe) + } + + @Test("除外対象のホスティングビューは走査しない") + func skipsRejectedHostingView() { + let root = UIView() + let hostingProbe = UIView() + hostingProbe.tag = MonitorConfiguration.default.rejectedTag + let element = UIAccessibilityElement(accessibilityContainer: hostingProbe) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + hostingProbe.accessibilityElements = [element] + root.addSubview(hostingProbe) + let scanner = ViewHierarchyScanner(isHostingView: { $0 === hostingProbe }) + + #expect(scanner.measurementTargets(in: root).isEmpty) + } + + @Test("既定のホスティングビュー判定は _UIHostingView を検出する") + func detectsRealHostingView() { + let hostingView = UIHostingController(rootView: Text("A")).view ?? UIView() + + #expect(ViewHierarchyScanner.isDefaultHostingView(hostingView)) + #expect(!ViewHierarchyScanner.isDefaultHostingView(UIView())) + } } From 2a48e2ab8df1123e421291d894781fc291646275 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 23:14:14 +0900 Subject: [PATCH 05/23] feat(ui): measure SwiftUI accessibility elements in the overlay SwiftUI targets have no backing UIView, so their monitor buttons attach to the root view at the element's window frame (no scroll tracking) and inspections re-read accessibilityFrame at selection time. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/UI/MonitorButton.swift | 2 +- Sources/ViewMonitor/UI/MonitorOverlay.swift | 100 ++++++++++++++---- .../ViewMonitorTests/MonitorButtonTests.swift | 18 ++-- .../MonitorOverlayTests.swift | 99 ++++++++++++++++- 4 files changed, 187 insertions(+), 32 deletions(-) diff --git a/Sources/ViewMonitor/UI/MonitorButton.swift b/Sources/ViewMonitor/UI/MonitorButton.swift index 5ab35a1..82746d0 100644 --- a/Sources/ViewMonitor/UI/MonitorButton.swift +++ b/Sources/ViewMonitor/UI/MonitorButton.swift @@ -6,5 +6,5 @@ import UIKit class MonitorButton: UIButton { - var targetView: UIView? + var measurementTarget: MeasurementTarget? } diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index cdd187d..36004e0 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -25,9 +25,17 @@ final class MonitorOverlay: NSObject { /// 自動的に nil に戻り、古い画面のビューとペアになることがない。 private weak var lastSelectedButton: MonitorButton? - init(configuration: MonitorConfiguration = .default) { + /// 座標変換の基準ウィンドウ。show(on:) で受け取った rootView を基準にする。 + /// WindowProvider.keyWindow を都度引き直すと、iPad のマルチシーン環境で + /// オーバーレイの取り付け先と foreground のシーンがずれたときに + /// 誤ったウィンドウで変換してしまう。 + private var currentWindow: UIWindow? { + rootView?.window ?? (rootView as? UIWindow) + } + + init(configuration: MonitorConfiguration = .default, scanner: ViewHierarchyScanner? = nil) { self.configuration = configuration - self.scanner = ViewHierarchyScanner(configuration: configuration) + self.scanner = scanner ?? ViewHierarchyScanner(configuration: configuration) super.init() } @@ -36,8 +44,8 @@ final class MonitorOverlay: NSObject { hide() self.rootView = rootView addInfoView(to: rootView) - for view in scanner.targets(in: rootView) { - addMonitorButton(on: view) + for target in scanner.measurementTargets(in: rootView) { + addMonitorButton(for: target, rootView: rootView) } } @@ -68,21 +76,37 @@ final class MonitorOverlay: NSObject { self.infoView = infoView } - private func addMonitorButton(on view: UIView) { - let button = MonitorButton(frame: CGRect(origin: .zero, size: view.frame.size)) + private func addMonitorButton(for target: MeasurementTarget, rootView: UIView) { + switch target { + case .uiKitView(let view): + let button = makeMonitorButton(for: target, frame: CGRect(origin: .zero, size: view.frame.size)) + if !view.isUserInteractionEnabled { + forcedInteractionViews.append(view) + view.isUserInteractionEnabled = true + } + view.addSubview(button) + case .accessibilityElement(let info): + guard let element = info.element else { + return + } + // ViewMonitor は keyWindow を rootView として渡すため window 座標 = rootView 座標。 + // 対象の UIView が存在しないので rootView 直下に固定配置する(スクロール非追従)。 + let frame = ViewInspector.inspect(element: element, kind: info.kind, in: currentWindow).frameInWindow + let button = makeMonitorButton(for: target, frame: frame) + rootView.addSubview(button) + } + } + + private func makeMonitorButton(for target: MeasurementTarget, frame: CGRect) -> MonitorButton { + let button = MonitorButton(frame: frame) let color = UIColor(monitorHex: configuration.overlayColorHex, alpha: configuration.overlayAlpha) ?? .green button.setBackgroundImage(.monitorSolidColor(color), for: .normal) button.titleLabel?.font = .systemFont(ofSize: 15.0) button.addTarget(self, action: #selector(select(sender:)), for: .touchUpInside) - button.targetView = view + button.measurementTarget = target button.alpha = 0.2 buttons.append(button) - - if !view.isUserInteractionEnabled { - forcedInteractionViews.append(view) - view.isUserInteractionEnabled = true - } - view.addSubview(button) + return button } /// 選択状態の切り替え。実行時はボタンの target-action からのみ呼ばれる。 @@ -97,19 +121,13 @@ final class MonitorOverlay: NSObject { var referenceButton: MonitorButton? if sender.isSelected { infoView.isHidden = false - // 座標変換は show(on:) で受け取った rootView を基準にする。 - // WindowProvider.keyWindow を都度引き直すと、iPad の - // マルチシーン環境でオーバーレイの取り付け先と foreground の - // シーンがずれたときに誤ったウィンドウで変換してしまう。 - let window = rootView?.window ?? (rootView as? UIWindow) - let inspection = sender.targetView.map { ViewInspector.inspect($0, in: window) } + let window = currentWindow + let inspection = currentInspection(of: sender, in: window) let reference: ViewInspection? = { - guard let last = lastSelectedButton, last !== sender, - let referenceView = last.targetView, - let referenceWindow = referenceView.window, referenceWindow === window else { + guard let last = lastSelectedButton, last !== sender else { return nil } - return ViewInspector.inspect(referenceView, in: window) + return referenceInspection(of: last, in: window) }() infoView.update(rows: InfoRowBuilder.rows(from: inspection, comparedTo: reference)) sender.layer.borderWidth = 2.0 @@ -137,4 +155,40 @@ final class MonitorOverlay: NSObject { view.center = CGPoint(x: view.center.x + translation.x, y: view.center.y + translation.y) sender.setTranslation(.zero, in: container) } + + /// ターゲットの現在値を計測する。要素が破棄されていれば nil。 + /// `select` 内のローカル変数 `inspection` と衝突しないよう current を冠する。 + private func currentInspection(of button: MonitorButton, in window: UIWindow?) -> ViewInspection? { + switch button.measurementTarget { + case .uiKitView(let view): + return ViewInspector.inspect(view, in: window) + case .accessibilityElement(let info): + guard let element = info.element else { + return nil + } + return ViewInspector.inspect(element: element, kind: info.kind, in: window) + case nil: + return nil + } + } + + /// 参照(距離計測の相手)の計測。取り付け先の window が現在の window と + /// 一致するときだけ有効。画面遷移後の古いターゲットとペアにしない。 + private func referenceInspection(of button: MonitorButton, in window: UIWindow?) -> ViewInspection? { + switch button.measurementTarget { + case .uiKitView(let view): + guard let referenceWindow = view.window, referenceWindow === window else { + return nil + } + return ViewInspector.inspect(view, in: window) + case .accessibilityElement(let info): + guard let hostingWindow = info.hostingView?.window, hostingWindow === window, + let element = info.element else { + return nil + } + return ViewInspector.inspect(element: element, kind: info.kind, in: window) + case nil: + return nil + } + } } diff --git a/Tests/ViewMonitorTests/MonitorButtonTests.swift b/Tests/ViewMonitorTests/MonitorButtonTests.swift index 9046416..6edf7f8 100644 --- a/Tests/ViewMonitorTests/MonitorButtonTests.swift +++ b/Tests/ViewMonitorTests/MonitorButtonTests.swift @@ -6,20 +6,24 @@ import UIKit @MainActor struct MonitorButtonTests { - @Test("targetView に設定したビューをそのまま保持する") - func retainsTargetView() { + @Test("measurementTarget に設定したビューをそのまま保持する") + func retainsMeasurementTarget() throws { let button = MonitorButton() let target = UIView() - button.targetView = target + button.measurementTarget = .uiKitView(target) - #expect(button.targetView === target) + guard case .uiKitView(let held) = try #require(button.measurementTarget) else { + Issue.record("uiKitView ではない") + return + } + #expect(held === target) } - @Test("targetView の初期値は nil である") - func targetViewIsNilByDefault() { + @Test("measurementTarget の初期値は nil である") + func measurementTargetIsNilByDefault() { let button = MonitorButton() - #expect(button.targetView == nil) + #expect(button.measurementTarget == nil) } } diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index 4879f12..e3e20cf 100644 --- a/Tests/ViewMonitorTests/MonitorOverlayTests.swift +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -24,6 +24,26 @@ struct MonitorOverlayTests { infoView(in: root)?.rowLabels.compactMap(\.text) ?? [] } + /// 疑似ホスティングビューと、その上のアクセシビリティ要素を1つ作る。 + private func makeAccessibilityProbe( + frame: CGRect, + label: String? = nil, + traits: UIAccessibilityTraits = .staticText + ) -> (host: UIView, element: UIAccessibilityElement) { + let host = UIView() + let element = UIAccessibilityElement(accessibilityContainer: host) + element.isAccessibilityElement = true + element.accessibilityTraits = traits + element.accessibilityLabel = label + element.accessibilityFrame = frame + host.accessibilityElements = [element] + return (host, element) + } + + private func makeOverlay(hostingProbe: UIView) -> MonitorOverlay { + MonitorOverlay(scanner: ViewHierarchyScanner(isHostingView: { $0 === hostingProbe })) + } + @Test("1つ目の選択では距離セクションが出ない") func firstSelectionHasNoDistanceSection() throws { let window = makeWindow() @@ -101,7 +121,7 @@ struct MonitorOverlayTests { window.addSubview(label) overlay.show(on: window) let button = try #require(monitorButton(on: label)) - button.targetView = nil + button.measurementTarget = nil overlay.select(sender: button) @@ -174,4 +194,81 @@ struct MonitorOverlayTests { #expect(labelButton.layer.borderWidth == 0.0) } + + @Test("SwiftUI 要素のボタンは rootView 直下に要素の frame で付く") + func attachesAccessibilityButtonToRootView() throws { + // makeWindow() は原点 (0, 0) のためスクリーン座標 = window 座標。 + let window = makeWindow() + let (host, _) = makeAccessibilityProbe(frame: CGRect(x: 16, y: 100, width: 120, height: 20)) + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + + overlay.show(on: window) + + let button = try #require(monitorButton(on: window)) + #expect(button.frame == CGRect(x: 16, y: 100, width: 120, height: 20)) + } + + @Test("SwiftUI 要素の選択で種別・テキスト行が出て、取れない項目は None になる") + func selectingAccessibilityTargetShowsKindAndText() throws { + let window = makeWindow() + let (host, _) = makeAccessibilityProbe( + frame: CGRect(x: 16, y: 100, width: 120, height: 20), + label: "Hello" + ) + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + overlay.show(on: window) + + overlay.select(sender: try #require(monitorButton(on: window))) + + let texts = rowTexts(in: window) + #expect(texts.contains("class: Text")) + #expect(texts.contains("text: Hello")) + #expect(texts.contains("alpha: None")) + #expect(texts.contains("cornerRadius: None")) + } + + @Test("要素が破棄された後の選択は全項目 None になる") + func deallocatedElementShowsNoneRows() throws { + let window = makeWindow() + let host = UIView() + var element: UIAccessibilityElement? = UIAccessibilityElement(accessibilityContainer: host) + element?.isAccessibilityElement = true + element?.accessibilityTraits = .staticText + element?.accessibilityFrame = CGRect(x: 16, y: 100, width: 120, height: 20) + host.accessibilityElements = element.map { [$0] } + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + overlay.show(on: window) + let button = try #require(monitorButton(on: window)) + + host.accessibilityElements = nil + element = nil + overlay.select(sender: button) + + let texts = rowTexts(in: window) + #expect(texts.count == 8) + #expect(texts.allSatisfy { $0.hasSuffix(": None") }) + } + + @Test("UIKit ビューと SwiftUI 要素の距離セクションが出る") + func distanceSectionAcrossUIKitAndSwiftUI() throws { + let window = makeWindow() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + let (host, _) = makeAccessibilityProbe(frame: CGRect(x: 16, y: 144, width: 120, height: 20)) + window.addSubview(label) + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + overlay.show(on: window) + let labelButton = try #require(monitorButton(on: label)) + let elementButton = try #require(monitorButton(on: window)) + + overlay.select(sender: labelButton) + overlay.select(sender: elementButton) + + let texts = rowTexts(in: window) + #expect(texts.contains("vs: UILabel")) + #expect(texts.contains("gapY: 24")) + } } From 3ef96d6b9a6a3798318c17b087740b3017e24c91 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 23:25:06 +0900 Subject: [PATCH 06/23] fix(ui): keep infoView above SwiftUI element monitor buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility-element buttons attach directly to rootView after infoView, so they painted and hit-tested above it whenever their frame overlapped the info panel — obscuring it and stealing its drag gesture. Re-assert bringSubviewToFront(infoView) after attaching all target buttons in show(on:). Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/UI/MonitorOverlay.swift | 6 ++++++ .../MonitorOverlayTests.swift | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index 36004e0..da43e75 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -47,6 +47,12 @@ final class MonitorOverlay: NSObject { for target in scanner.measurementTargets(in: rootView) { addMonitorButton(for: target, rootView: rootView) } + // SwiftUI 要素のボタンは rootView に直接addSubviewするため、 + // infoView より後に追加されると重なり順で上に乗ってしまう。 + // ドラッグ用ジェスチャの奪い合いを防ぐため、追加後に最前面へ戻す。 + if let infoView { + rootView.bringSubviewToFront(infoView) + } } /// オーバーレイを取り除き、変更したビューの状態を元に戻す。 diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index e3e20cf..6a70421 100644 --- a/Tests/ViewMonitorTests/MonitorOverlayTests.swift +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -271,4 +271,23 @@ struct MonitorOverlayTests { #expect(texts.contains("vs: UILabel")) #expect(texts.contains("gapY: 24")) } + + @Test("infoView と重なる SwiftUI 要素のボタンを追加しても infoView が最前面に留まる") + func infoViewStaysInFrontOfOverlappingAccessibilityButton() throws { + // infoView は (width - 220, 70) 付近に表示される。SwiftUI 要素のボタンは + // rootView に直接addSubviewされるため、対策前は後から追加された分だけ + // infoView より前面に来てドラッグ操作やタップを奪ってしまっていた。 + let window = makeWindow() + let (host, _) = makeAccessibilityProbe(frame: CGRect(x: 150, y: 80, width: 100, height: 40)) + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + + overlay.show(on: window) + + let button = try #require(monitorButton(on: window)) + let info = try #require(infoView(in: window)) + let buttonIndex = try #require(window.subviews.firstIndex { $0 === button }) + let infoIndex = try #require(window.subviews.firstIndex { $0 === info }) + #expect(infoIndex > buttonIndex) + } } From 2a60390a510f1aeda59d0df8ba7a2f89d84772ee Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 23:33:20 +0900 Subject: [PATCH 07/23] feat(api): add View.viewMonitor() for SwiftUI lifecycle apps SwiftUI apps have no SceneDelegate, so expose a root-view modifier that starts the monitor on first appear. Screen-change detection keeps using the existing viewDidAppear swizzling, which UIHostingController hits. Co-Authored-By: Claude Fable 5 --- .../SwiftUI/View+ViewMonitor.swift | 18 ++++++++++++++ Sources/ViewMonitor/ViewMonitor.swift | 3 +++ .../ViewMonitorModifierTests.swift | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 Sources/ViewMonitor/SwiftUI/View+ViewMonitor.swift create mode 100644 Tests/ViewMonitorTests/ViewMonitorModifierTests.swift diff --git a/Sources/ViewMonitor/SwiftUI/View+ViewMonitor.swift b/Sources/ViewMonitor/SwiftUI/View+ViewMonitor.swift new file mode 100644 index 0000000..a45cdfb --- /dev/null +++ b/Sources/ViewMonitor/SwiftUI/View+ViewMonitor.swift @@ -0,0 +1,18 @@ +import SwiftUI + +public extension View { + + /// SwiftUI ライフサイクルのアプリで ViewMonitor を起動する。 + /// + /// WindowGroup { + /// ContentView() + /// .viewMonitor() + /// } + /// + /// 起動済みなら何もしないため、複数回 appear しても無害。 + func viewMonitor() -> some View { + onAppear { + ViewMonitor.start() + } + } +} diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index bdd9c4d..f559118 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -85,6 +85,9 @@ public final class ViewMonitor: NSObject { // MARK: - Testing seam + /// テスト用: 起動済みかどうか。公開 API には含まれない。 + static var isStartedForTesting: Bool { shared.started } + /// テスト用: `rootView` に任意の `UIView` を注入し、実行ボタンを追加した状態を再現する。 /// ユニットテストのバンドルには接続済みの window scene が無く /// `WindowProvider.keyWindow` が常に nil になるため、`detectedViewDidAppear()` diff --git a/Tests/ViewMonitorTests/ViewMonitorModifierTests.swift b/Tests/ViewMonitorTests/ViewMonitorModifierTests.swift new file mode 100644 index 0000000..50a98fc --- /dev/null +++ b/Tests/ViewMonitorTests/ViewMonitorModifierTests.swift @@ -0,0 +1,24 @@ +import SwiftUI +import Testing +import UIKit +@testable import ViewMonitor + +@Suite("View+ViewMonitor") +@MainActor +struct ViewMonitorModifierTests { + + @Test("viewMonitor モディファイアは appear 時に計測を開始する") + func startsOnAppear() { + // 他テストが起動したままでも成立するよう先に止めてから検証する。 + ViewMonitor.stop() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + window.rootViewController = UIHostingController(rootView: Color.clear.viewMonitor()) + window.makeKeyAndVisible() + window.rootViewController?.view.layoutIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2)) + + #expect(ViewMonitor.isStartedForTesting) + + ViewMonitor.stop() + } +} From b59e431ca8f0f22fb7fd2569c121334d3627c678 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 23:40:50 +0900 Subject: [PATCH 08/23] refactor(ui): render InfoView rows with SwiftUI InfoView keeps its UIKit contract (frame-managed size, drag by the overlay) and delegates drawing to a hosted InfoRowsView. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/UI/InfoRowsView.swift | 21 +++++++++ Sources/ViewMonitor/UI/InfoView.swift | 44 ++++++------------- Tests/ViewMonitorTests/InfoViewTests.swift | 17 ++----- .../MonitorOverlayTests.swift | 2 +- 4 files changed, 39 insertions(+), 45 deletions(-) create mode 100644 Sources/ViewMonitor/UI/InfoRowsView.swift diff --git a/Sources/ViewMonitor/UI/InfoRowsView.swift b/Sources/ViewMonitor/UI/InfoRowsView.swift new file mode 100644 index 0000000..878ed5a --- /dev/null +++ b/Sources/ViewMonitor/UI/InfoRowsView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// InfoRow の配列を描画するだけのビュー。行の組み立ては InfoRowBuilder が担う。 +struct InfoRowsView: View { + + let rows: [InfoRow] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + Text("\(row.title): \(row.value)") + .font(.system(size: 11)) + .foregroundColor(.white) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 10, leading: 22, bottom: 10, trailing: 10)) + // 浮動パネル内で safe area にレイアウトを動かされない。 + .ignoresSafeArea() + } +} diff --git a/Sources/ViewMonitor/UI/InfoView.swift b/Sources/ViewMonitor/UI/InfoView.swift index 08d5f52..4ca4226 100644 --- a/Sources/ViewMonitor/UI/InfoView.swift +++ b/Sources/ViewMonitor/UI/InfoView.swift @@ -3,36 +3,28 @@ // ViewMonitor // +import SwiftUI import UIKit /// 計測結果の行を表示するだけのビュー。計測ロジックも行の組み立ても持たない。 +/// 描画は SwiftUI(InfoRowsView)に委譲し、UIKit 側は取り付けとサイズ管理だけを担う。 final class InfoView: UIView { /// 表示幅。高さは行数に応じて `update(rows:)` が決める。 static let width: CGFloat = 200.0 - private static let contentInsets = UIEdgeInsets(top: 10.0, left: 22.0, bottom: 10.0, right: 10.0) + private let hostingController = UIHostingController(rootView: InfoRowsView(rows: [])) - private let stackView = UIStackView() - - /// 現在表示中の行ラベル。表示内容の検証用。 - var rowLabels: [UILabel] { - stackView.arrangedSubviews.compactMap { $0 as? UILabel } - } + /// 現在表示中の行。表示内容の検証用。 + var displayedRows: [InfoRow] { hostingController.rootView.rows } override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 10.0 - stackView.axis = .vertical - stackView.spacing = 6.0 - stackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: topAnchor, constant: Self.contentInsets.top), - stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.contentInsets.left), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.contentInsets.right), - stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.contentInsets.bottom) - ]) + hostingController.view.backgroundColor = .clear + hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostingController.view.frame = bounds + addSubview(hostingController.view) update(rows: []) } @@ -47,21 +39,11 @@ final class InfoView: UIView { /// 自身のサイズは frame 管理のまま(superview への制約なし)にして、 /// MonitorOverlay のドラッグ移動(center の書き換え)と衝突させない。 func update(rows: [InfoRow]) { - for view in stackView.arrangedSubviews { - view.removeFromSuperview() - } - for row in rows { - let label = UILabel() - label.textColor = .white - label.font = .systemFont(ofSize: 11) - label.text = "\(row.title): \(row.value)" - stackView.addArrangedSubview(label) - } - let height = systemLayoutSizeFitting( - CGSize(width: Self.width, height: UIView.layoutFittingCompressedSize.height), - withHorizontalFittingPriority: .required, - verticalFittingPriority: .fittingSizeLevel + hostingController.rootView = InfoRowsView(rows: rows) + let height = hostingController.sizeThatFits( + in: CGSize(width: Self.width, height: .greatestFiniteMagnitude) ).height frame.size = CGSize(width: Self.width, height: height) + hostingController.view.frame = bounds } } diff --git a/Tests/ViewMonitorTests/InfoViewTests.swift b/Tests/ViewMonitorTests/InfoViewTests.swift index 89d2a57..c7a87ed 100644 --- a/Tests/ViewMonitorTests/InfoViewTests.swift +++ b/Tests/ViewMonitorTests/InfoViewTests.swift @@ -31,22 +31,13 @@ struct InfoViewTests { ] } - @Test("行を順序通りに title: value 形式で描画する") + @Test("行を順序通りに保持して SwiftUI ビューへ渡す") func rendersRowsInOrder() { let infoView = makeInfoView() infoView.update(rows: commonRows) - #expect(infoView.rowLabels.map(\.text) == [ - "class: UIView", - "x: 16", - "y: 120", - "width: 343", - "height: 20", - "background: None", - "alpha: 1", - "cornerRadius: 0" - ]) + #expect(infoView.displayedRows == commonRows) } @Test("行数が増えると高さが伸び、幅は 200 のまま") @@ -70,8 +61,8 @@ struct InfoViewTests { infoView.update(rows: commonRows) - #expect(infoView.rowLabels.count == 8) - #expect(infoView.rowLabels.compactMap(\.text).allSatisfy { !$0.contains("Helvetica") }) + #expect(infoView.displayedRows.count == 8) + #expect(infoView.displayedRows.allSatisfy { $0.value != "Helvetica" }) } @Test("ドラッグ相当の origin 変更後も update で origin が動かない") diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index 6a70421..66dda8b 100644 --- a/Tests/ViewMonitorTests/MonitorOverlayTests.swift +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -21,7 +21,7 @@ struct MonitorOverlayTests { } private func rowTexts(in root: UIView) -> [String] { - infoView(in: root)?.rowLabels.compactMap(\.text) ?? [] + infoView(in: root)?.displayedRows.map { "\($0.title): \($0.value)" } ?? [] } /// 疑似ホスティングビューと、その上のアクセシビリティ要素を1つ作る。 From 446142e616d94b615a28d0989a4c09216b8bc1da Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 00:24:27 +0900 Subject: [PATCH 09/23] feat(example): add SwiftUI lifecycle example app Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 7 + .../Signing.xcconfig | 14 + .../project.pbxproj | 337 ++++++++++++++++++ .../ViewMonitorSwiftUIExample.xcscheme | 87 +++++ .../ContentView.swift | 34 ++ .../ViewMonitorSwiftUIExampleApp.swift | 12 + .../generate_project.rb | 98 +++++ 7 files changed, 589 insertions(+) create mode 100644 Example/ViewMonitorSwiftUIExample/Signing.xcconfig create mode 100644 Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/project.pbxproj create mode 100644 Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorSwiftUIExample.xcscheme create mode 100644 Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ContentView.swift create mode 100644 Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift create mode 100644 Example/ViewMonitorSwiftUIExample/generate_project.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ca9568..39aa96d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,13 @@ jobs: -scheme ViewMonitorExample \ -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" + - name: Build SwiftUI example + run: | + xcodebuild build \ + -project Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj \ + -scheme ViewMonitorSwiftUIExample \ + -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" + pod-lint: name: pod lib lint runs-on: macos-latest diff --git a/Example/ViewMonitorSwiftUIExample/Signing.xcconfig b/Example/ViewMonitorSwiftUIExample/Signing.xcconfig new file mode 100644 index 0000000..cb38368 --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/Signing.xcconfig @@ -0,0 +1,14 @@ +// Code signing settings for ViewMonitorSwiftUIExample. +// +// Building for a device needs a DEVELOPMENT_TEAM, but the value differs per +// developer and this is a public repository, so it lives neither in the +// pbxproj nor in this file. Create a Local.xcconfig as below and Cmd+R in +// Xcode will deploy to a device (you can find your Team ID under Signing & +// Capabilities in Xcode, or on your Apple Developer membership page): +// +// echo 'DEVELOPMENT_TEAM = XXXXXXXXXX' > Example/ViewMonitorSwiftUIExample/Local.xcconfig +// +// Local.xcconfig is git-ignored. The include below is optional (#include?) so +// builds don't break where the file is absent, such as on CI. Simulator builds +// don't need signing at all, so they need no setting here. +#include? "Local.xcconfig" diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/project.pbxproj b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e77fedf --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/project.pbxproj @@ -0,0 +1,337 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 9D3E06DCEEC09745F88A635C /* ViewMonitorSwiftUIExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBA02CAC4239B0C02C6F642 /* ViewMonitorSwiftUIExampleApp.swift */; }; + C0CDF57005D23B4DCB014E29 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C4B9CF1CC0C3CDB4A351F3 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0EBA02CAC4239B0C02C6F642 /* ViewMonitorSwiftUIExampleApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewMonitorSwiftUIExampleApp.swift; sourceTree = ""; }; + 23C4B9CF1CC0C3CDB4A351F3 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 509AB8F272B9805C09EF2A08 /* ViewMonitorSwiftUIExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewMonitorSwiftUIExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 98BB70B66795A38A430A5454 /* Signing.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + DA9FF015503021E80DBD010A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5EFCA5F5108BD64E6944B7E8 /* Products */ = { + isa = PBXGroup; + children = ( + 509AB8F272B9805C09EF2A08 /* ViewMonitorSwiftUIExample.app */, + ); + name = Products; + sourceTree = ""; + }; + 63C2FE9CC903968BEB57A378 = { + isa = PBXGroup; + children = ( + 5EFCA5F5108BD64E6944B7E8 /* Products */, + 76ACFF22299DDF00C7F1862B /* Frameworks */, + 69FE07EAE7EB9541D3177C23 /* ViewMonitorSwiftUIExample */, + 98BB70B66795A38A430A5454 /* Signing.xcconfig */, + ); + sourceTree = ""; + }; + 69FE07EAE7EB9541D3177C23 /* ViewMonitorSwiftUIExample */ = { + isa = PBXGroup; + children = ( + 0EBA02CAC4239B0C02C6F642 /* ViewMonitorSwiftUIExampleApp.swift */, + 23C4B9CF1CC0C3CDB4A351F3 /* ContentView.swift */, + ); + name = ViewMonitorSwiftUIExample; + path = ViewMonitorSwiftUIExample; + sourceTree = ""; + }; + 76ACFF22299DDF00C7F1862B /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A3156E7133E7E80387CAD402 /* ViewMonitorSwiftUIExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = BF1C0A3270B9F85CEA4CD32E /* Build configuration list for PBXNativeTarget "ViewMonitorSwiftUIExample" */; + buildPhases = ( + 63EB3E442AF8EA21899BB242 /* Sources */, + DA9FF015503021E80DBD010A /* Frameworks */, + 5C16A2E3408B47ABAF1390CD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ViewMonitorSwiftUIExample; + packageProductDependencies = ( + B3230F6343F7EFB6B6DF459F /* ViewMonitor */, + ); + productName = ViewMonitorSwiftUIExample; + productReference = 509AB8F272B9805C09EF2A08 /* ViewMonitorSwiftUIExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 98D0D799360F71CE242702AD /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = F3F42AAB82426067B2C33587 /* Build configuration list for PBXProject "ViewMonitorSwiftUIExample" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 63C2FE9CC903968BEB57A378; + minimizedProjectReferenceProxies = 0; + packageReferences = ( + 6F1EE87498990AF7286F9126 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 100; + productRefGroup = 5EFCA5F5108BD64E6944B7E8 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A3156E7133E7E80387CAD402 /* ViewMonitorSwiftUIExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5C16A2E3408B47ABAF1390CD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 63EB3E442AF8EA21899BB242 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9D3E06DCEEC09745F88A635C /* ViewMonitorSwiftUIExampleApp.swift in Sources */, + C0CDF57005D23B4DCB014E29 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 36575AD06C3E0C3A6D16849F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 98BB70B66795A38A430A5454 /* Signing.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorSwiftUIExample; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4B835E7CDFC26D5D8E022AC8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 5FACFFF8B63151A51BDDD355 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 98BB70B66795A38A430A5454 /* Signing.xcconfig */; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = daisuke.ViewMonitorSwiftUIExample; + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 6607C4F36ADC408EF50C0438 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BF1C0A3270B9F85CEA4CD32E /* Build configuration list for PBXNativeTarget "ViewMonitorSwiftUIExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5FACFFF8B63151A51BDDD355 /* Release */, + 36575AD06C3E0C3A6D16849F /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F3F42AAB82426067B2C33587 /* Build configuration list for PBXProject "ViewMonitorSwiftUIExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B835E7CDFC26D5D8E022AC8 /* Debug */, + 6607C4F36ADC408EF50C0438 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 6F1EE87498990AF7286F9126 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B3230F6343F7EFB6B6DF459F /* ViewMonitor */ = { + isa = XCSwiftPackageProductDependency; + productName = ViewMonitor; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 98D0D799360F71CE242702AD /* Project object */; +} diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorSwiftUIExample.xcscheme b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorSwiftUIExample.xcscheme new file mode 100644 index 0000000..3f17268 --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample.xcodeproj/xcshareddata/xcschemes/ViewMonitorSwiftUIExample.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ContentView.swift b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ContentView.swift new file mode 100644 index 0000000..26cc7a7 --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ContentView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + // iOS 15 対応のため NavigationStack ではなく NavigationView を使う。 + NavigationView { + VStack(alignment: .leading, spacing: 16) { + Text("Hello, ViewMonitor!") + .font(.title2) + Image(systemName: "viewfinder") + .font(.system(size: 48)) + Button("Tap me") {} + .buttonStyle(.bordered) + NavigationLink("Show List") { + ListScreen() + } + Spacer() + } + .padding() + .navigationTitle("SwiftUI Example") + } + .navigationViewStyle(.stack) + } +} + +/// スクロール時の計測ボタン非追従(既知の制限)を確認するための画面。 +struct ListScreen: View { + var body: some View { + List(0..<30, id: \.self) { index in + Text("Row \(index)") + } + .navigationTitle("List") + } +} diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift new file mode 100644 index 0000000..1a5a248 --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift @@ -0,0 +1,12 @@ +import SwiftUI +import ViewMonitor + +@main +struct ViewMonitorSwiftUIExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .viewMonitor() + } + } +} diff --git a/Example/ViewMonitorSwiftUIExample/generate_project.rb b/Example/ViewMonitorSwiftUIExample/generate_project.rb new file mode 100644 index 0000000..99b8857 --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/generate_project.rb @@ -0,0 +1,98 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Generates ViewMonitorSwiftUIExample.xcodeproj so the project can be +# recreated without going through the Xcode GUI. +# +# bundle exec ruby Example/ViewMonitorSwiftUIExample/generate_project.rb +# +# The generated project is committed. To change a project setting, edit this +# script and re-run it rather than editing the project in Xcode. + +require 'xcodeproj' +require 'fileutils' + +ROOT = File.expand_path('../..', __dir__) +EXAMPLE_DIR = File.join(ROOT, 'Example', 'ViewMonitorSwiftUIExample') +APP_DIR = File.join(EXAMPLE_DIR, 'ViewMonitorSwiftUIExample') +PROJECT_PATH = File.join(EXAMPLE_DIR, 'ViewMonitorSwiftUIExample.xcodeproj') +TARGET_NAME = 'ViewMonitorSwiftUIExample' +BUNDLE_ID = 'daisuke.ViewMonitorSwiftUIExample' +DEPLOYMENT_TARGET = '15.0' +XCCONFIG_NAME = 'Signing.xcconfig' + +FileUtils.rm_rf(PROJECT_PATH) +project = Xcodeproj::Project.new(PROJECT_PATH) +project.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET + config.build_settings['SWIFT_VERSION'] = '6.0' +end + +target = project.new_target(:application, TARGET_NAME, :ios, DEPLOYMENT_TARGET) + +# `new_target` links Foundation.framework by default, but its file reference +# hardcodes the version of the installed SDK into the path (e.g. +# iPhoneOS26.0.sdk, sourceTree = DEVELOPER_DIR). The link is unnecessary +# (Swift/UIKit apps link Foundation automatically) and it bakes the generating +# machine's SDK version into the pbxproj, so regenerating elsewhere produces a +# spurious diff. Drop it from the build phase and also remove the file +# reference and its parent group (Frameworks/iOS) from the project entirely. +target.frameworks_build_phase.clear +if (ios_frameworks_group = project.frameworks_group['iOS']) + ios_frameworks_group.children.each(&:remove_from_project) + ios_frameworks_group.remove_from_project +end + +group = project.new_group(TARGET_NAME, 'ViewMonitorSwiftUIExample') + +sources = %w[ViewMonitorSwiftUIExampleApp.swift ContentView.swift] +sources.each do |name| + file = group.new_reference(File.join(APP_DIR, name)) + target.add_file_references([file]) +end + +# Reference the root Package.swift locally and link ViewMonitor from it. +package_ref = project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference) +package_ref.relative_path = '../..' +project.root_object.package_references << package_ref + +product_ref = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) +product_ref.product_name = 'ViewMonitor' +target.package_product_dependencies << product_ref + +# DEVELOPMENT_TEAM differs per developer, so it is not baked into the pbxproj. +# It comes from a git-ignored Local.xcconfig instead; see Signing.xcconfig. +xcconfig_ref = project.main_group.new_reference(File.join(EXAMPLE_DIR, XCCONFIG_NAME)) + +target.build_configurations.each do |config| + config.base_configuration_reference = xcconfig_ref + + settings = config.build_settings + settings['PRODUCT_BUNDLE_IDENTIFIER'] = BUNDLE_ID + settings['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET + settings['SWIFT_VERSION'] = '6.0' + # A pure-SwiftUI app needs no storyboard or scene manifest, so the plist is + # fully generated. Launch-screen generation avoids letterboxed rendering. + settings['GENERATE_INFOPLIST_FILE'] = 'YES' + settings['INFOPLIST_KEY_UILaunchScreen_Generation'] = 'YES' + settings['TARGETED_DEVICE_FAMILY'] = '1,2' + # Don't require signing for the simulator: CI has neither a signing + # certificate nor a DEVELOPMENT_TEAM, so the example build job fails without + # this. Devices (iphoneos) can't install an unsigned build, so the setting is + # conditioned on the SDK and device builds sign as usual. + settings['CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]'] = 'NO' + settings['CODE_SIGN_STYLE'] = 'Automatic' + # There is no asset catalog, so drop the gem's defaults that reference one. + settings.delete('ASSETCATALOG_COMPILER_APPICON_NAME') + settings.delete('ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME') +end + +project.save + +# Create a shared scheme so CI can refer to it with -scheme. +scheme = Xcodeproj::XCScheme.new +scheme.add_build_target(target) +scheme.set_launch_target(target) +scheme.save_as(PROJECT_PATH, TARGET_NAME, true) + +puts "Generated #{PROJECT_PATH}" From 23a105bbcd0f9756acad98bc4b47620cff0b039b Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 00:35:09 +0900 Subject: [PATCH 10/23] docs: document SwiftUI support and limitations Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9044022..0a6c959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- SwiftUI support: `Text` / `Image` / `Button` in SwiftUI hierarchies are + now detected through the accessibility elements SwiftUI publishes, with + position, size, text content, and cross-framework distance measurement. + Detection requires an active accessibility client (e.g. VoiceOver or + Accessibility Inspector); see README for details. +- `View.viewMonitor()` modifier to start ViewMonitor from apps using the + SwiftUI lifecycle. +- A SwiftUI example app at `Example/ViewMonitorSwiftUIExample`. + +### Changed + +- Screens embedding `UIHostingController` now show monitor buttons for + SwiftUI elements as well. +- `InfoView` renders its rows with SwiftUI internally (no behavior change). + ## [2.2.0] - 2026-08-06 ### Added diff --git a/README.md b/README.md index 267b3ef..7b1f4dd 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,51 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { Once running, tap the button that appears in the top-right corner of the screen to start measuring. A working sample project is available at `Example/ViewMonitorExample`. +### SwiftUI + +For apps using the SwiftUI lifecycle (`@main App`), attach `.viewMonitor()` +to the root view: + +```swift +import SwiftUI +import ViewMonitor + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .viewMonitor() + } + } +} +``` + +Calling `ViewMonitor.start()` from `App.init()` also works. + +SwiftUI's `Text`, `Image`, and `Button` are detected through the +accessibility elements SwiftUI publishes, so measurement works without any +changes to your views. A SwiftUI sample project is available at +`Example/ViewMonitorSwiftUIExample`. + +Known limitations for SwiftUI elements: + +- SwiftUI elements are detected through the accessibility tree, which iOS + builds only while an accessibility client is active (VoiceOver, UI tests, + or Xcode's Accessibility Inspector). If no monitor buttons appear over + SwiftUI views, attach Accessibility Inspector (Xcode > Open Developer + Tool > Accessibility Inspector) or enable VoiceOver, then reopen the + screen. On the simulator you can also run + `xcrun simctl spawn booted defaults write com.apple.Accessibility AutomationEnabled 1` + before launching the app. +- Measured values are limited to position, size, and text content + (font / background / cornerRadius show `None`). +- Monitor buttons do not follow scrolling; they refresh on screen + transitions. +- Views combined with `.accessibilityElement(children: .combine)` are + measured as a single element, and `.accessibilityHidden(true)` views are + not detected. + ### Running the sample on a device The sample runs on the simulator as-is. To run it on a physical device, code From 4e19afe69775b062509d3cb949443bedb68e7125 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 00:37:23 +0900 Subject: [PATCH 11/23] docs(changelog): write unreleased entry in Japanese Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6c959..1397812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,20 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- SwiftUI support: `Text` / `Image` / `Button` in SwiftUI hierarchies are - now detected through the accessibility elements SwiftUI publishes, with - position, size, text content, and cross-framework distance measurement. - Detection requires an active accessibility client (e.g. VoiceOver or - Accessibility Inspector); see README for details. -- `View.viewMonitor()` modifier to start ViewMonitor from apps using the - SwiftUI lifecycle. -- A SwiftUI example app at `Example/ViewMonitorSwiftUIExample`. +- SwiftUI 対応。SwiftUI 画面の `Text` / `Image` / `Button` を、SwiftUI が VoiceOver 向けに公開するアクセシビリティ要素経由で検出し、位置・サイズ・テキスト内容の計測と UIKit ビューとの距離計測に対応。検出にはアクセシビリティクライアント(VoiceOver / Accessibility Inspector など)が有効である必要がある。詳細は README を参照 +- SwiftUI ライフサイクルのアプリから起動するための `View.viewMonitor()` モディファイア +- SwiftUI サンプルアプリ(`Example/ViewMonitorSwiftUIExample`) ### Changed -- Screens embedding `UIHostingController` now show monitor buttons for - SwiftUI elements as well. -- `InfoView` renders its rows with SwiftUI internally (no behavior change). +- `UIHostingController` を埋め込んだ画面でも SwiftUI 要素に計測ボタンが表示されるように +- `InfoView` の内部描画を SwiftUI 化(挙動変更なし) ## [2.2.0] - 2026-08-06 From 7ba0db5c27adddb4135e0a9982935840ad7d1ee4 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 01:12:27 +0900 Subject: [PATCH 12/23] fix(ui): keep launcher above SwiftUI element monitor buttons MonitorOverlay adds SwiftUI accessibility-element monitor buttons directly to rootView, landing them above the launcher button that ViewMonitor.reload() had already added. If a SwiftUI element overlaps the launcher's top-right corner, taps meant to stop measurement hit the element button instead and the launcher's drag gesture is blocked. Re-front the launcher after overlay.show(on:) in the onToggle closure. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/ViewMonitor.swift | 4 ++ .../ViewMonitorLifecycleTests.swift | 60 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index f559118..4161511 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -74,6 +74,10 @@ public final class ViewMonitor: NSObject { } if isSelected { self.overlay.show(on: rootView) + // SwiftUI 要素のボタンは rootView に直接addSubviewされるため、 + // show(on:) の後だと実行ボタンより前面に乗ってしまう。 + // 実行ボタン(停止操作)がタップやドラッグを奪われないよう最前面に戻す。 + rootView.bringSubviewToFront(button) } else { self.overlay.hide() } diff --git a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift index 17c4e69..f04672d 100644 --- a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift +++ b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift @@ -2,7 +2,22 @@ import Testing import UIKit @testable import ViewMonitor -@Suite("ViewMonitor lifecycle") +/// 既定のスキャナ (`ViewHierarchyScanner.isDefaultHostingView`) はクラス名の +/// 文字列前方一致だけで SwiftUI のホスティングビューを判定する。 +/// 実際に `UIHostingController` をこのテストバンドル(ホストアプリを持たない +/// ユニットテスト実行環境)内で window に取り付けずに使うと、SwiftUI 側の +/// オフスクリーン計測が `UIViewController.viewDidAppear` を発火させることがあり、 +/// ViewMonitor が起動中はこれを画面遷移とみなして無関係に `reload()` してしまう +/// (このテストバンドルでは `WindowProvider.keyWindow` が常に nil のため、 +/// `reload()` は実行ボタンを取り除いたまま再アタッチできず消えてしまう)。 +/// 型名だけこの規約に合わせた軽量ダミーを使い、本物の `UIHostingController` を +/// 経由せずに既定スキャナの検出経路を再現する。 +private final class _UIHostingViewProbe: UIView {} + +// ViewMonitor.shared はプロセス内で共有される単一のインスタンスのため、 +// テストを並行実行すると一方の stop() がもう一方の実行ボタンを +// ビュー階層から奪ってしまう。.serialized で直列実行を強制する。 +@Suite("ViewMonitor lifecycle", .serialized) @MainActor struct ViewMonitorLifecycleTests { @@ -22,4 +37,47 @@ struct ViewMonitorLifecycleTests { // 「stop 後は何も起きない」という前提が崩れる。 #expect(!container.subviews.contains { $0 is MonitorLauncherButton }) } + + @Test("SwiftUI 要素と重なっていても実行ボタンは最前面に留まる") + func launcherStaysInFrontOfOverlappingAccessibilityButton() throws { + // 実行ボタンは reload() で先に addSubview されるため、対策前は + // overlay.show(on:) が後から rootView 直下に追加する SwiftUI 要素の + // ボタンに前面を奪われ、停止タップやドラッグが要素側に吸われていた。 + // + // ViewMonitor.start() は呼ばない: simulateLauncherButtonAttachedForTesting は + // started 状態に依存せず実行ボタンを取り付けられる。start() を呼ぶと + // viewDidAppear の swizzling が有効化され、addInfoView() 内で + // UIHostingController.sizeThatFits が誘発する viewDidAppear が + // 無関係な reload() を引き起こしてしまう(このテストバンドルには + // 接続中の window scene が無いため reload() が実行ボタンを再アタッチできない)。 + ViewMonitor.stop() + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + // 既定のスキャナ(_UIHostingView プレフィックス判定)で検出されることを確認する。 + let host = _UIHostingViewProbe(frame: CGRect(x: 200, y: 0, width: 120, height: 100)) + #expect(ViewHierarchyScanner.isDefaultHostingView(host)) + let element = UIAccessibilityElement(accessibilityContainer: host) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + // 実行ボタンは右上 (x: 228, y: 20, w: 72, h: 49) 付近に置かれるので、 + // それと重なる領域に要素を置く。 + element.accessibilityFrame = CGRect(x: 220, y: 10, width: 100, height: 80) + host.accessibilityElements = [element] + window.addSubview(host) + + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + let launcher = try #require(window.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + + // 実行ボタンの tap から呼ばれる内部シームを直接発火させる。 + launcher.onToggle?(true) + + let elementButton = try #require(window.subviews.compactMap { $0 as? MonitorButton }.first) + let launcherIndex = try #require(window.subviews.firstIndex { $0 === launcher }) + let elementButtonIndex = try #require(window.subviews.firstIndex { $0 === elementButton }) + #expect(launcherIndex > elementButtonIndex) + + // 後始末: overlay を閉じる。実行ボタン自体は window ごと ARC で解放される。 + launcher.onToggle?(false) + } } From 61d514b8dddc5e1fda61ff4a398a43e38256d1e8 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 01:12:32 +0900 Subject: [PATCH 13/23] chore(lint): include SwiftUI example in swiftlint scope Example/ViewMonitorSwiftUIExample was added without being listed in .swiftlint.yml's included paths, leaving it unlinted. Co-Authored-By: Claude Fable 5 --- .swiftlint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index 9180b8e..670c998 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ included: - Sources - Tests - Example/ViewMonitorExample/ViewMonitorExample + - Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample excluded: - .build From a097657fb6fe483e72daf74005b382b047cd7974 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 01:12:37 +0900 Subject: [PATCH 14/23] test(ui): rename stale targetView reference The removed targetView property no longer exists; the test now exercises measurementTarget, so its display name and function name should say so. Co-Authored-By: Claude Fable 5 --- Tests/ViewMonitorTests/MonitorOverlayTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index 66dda8b..b6e1ece 100644 --- a/Tests/ViewMonitorTests/MonitorOverlayTests.swift +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -112,8 +112,8 @@ struct MonitorOverlayTests { #expect(!rowTexts(in: window).contains { $0.hasPrefix("vs:") }) } - @Test("targetView が nil のボタンを選択すると全項目 None になる") - func nilTargetShowsNoneRows() throws { + @Test("measurementTarget が nil のボタンを選択すると全項目 None になる") + func nilMeasurementTargetShowsNoneRows() throws { // フェーズAで未検証だった防御的経路の回収。 let window = makeWindow() let overlay = MonitorOverlay() From 0a8e20e850aa2be013b038792cfef39249891160 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Fri, 7 Aug 2026 01:25:43 +0900 Subject: [PATCH 15/23] fix(ui): break launcher button retain cycle in onToggle button.onToggle captured its own button strongly while removeLauncherButton() never clears onToggle, forming a button -> onToggle -> button self-cycle. Since reload() runs on every viewDidAppear/orientation change while started, every screen transition leaked the superseded launcher instance. Capture button weakly alongside self, matching the existing weak-self pattern. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/ViewMonitor.swift | 9 ++++- .../ViewMonitorLifecycleTests.swift | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index 4161511..87b3935 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -68,7 +68,10 @@ public final class ViewMonitor: NSObject { safeAreaInsets: rootView.safeAreaInsets ) let button = MonitorLauncherButton(origin: origin) - button.onToggle = { [weak self] isSelected in + // button を強参照キャプチャすると button.onToggle → button の自己参照サイクルになり、 + // removeLauncherButton() は onToggle をクリアしないため reload() のたびに + // 直前の実行ボタンがリークする。button も weak で受ける。 + button.onToggle = { [weak self, weak button] isSelected in guard let self, let rootView = self.rootView else { return } @@ -77,7 +80,9 @@ public final class ViewMonitor: NSObject { // SwiftUI 要素のボタンは rootView に直接addSubviewされるため、 // show(on:) の後だと実行ボタンより前面に乗ってしまう。 // 実行ボタン(停止操作)がタップやドラッグを奪われないよう最前面に戻す。 - rootView.bringSubviewToFront(button) + if let button { + rootView.bringSubviewToFront(button) + } } else { self.overlay.hide() } diff --git a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift index f04672d..c450a1a 100644 --- a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift +++ b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift @@ -80,4 +80,44 @@ struct ViewMonitorLifecycleTests { // 後始末: overlay を閉じる。実行ボタン自体は window ごと ARC で解放される。 launcher.onToggle?(false) } + + @Test("画面遷移で実行ボタンが差し替わると直前のボタンが解放される") + func replacedLauncherButtonIsDeallocated() throws { + // onToggle クロージャが自身の button を強参照キャプチャすると + // button → onToggle → button の自己参照サイクルになる。 + // removeLauncherButton() は onToggle をクリアしないため、reload() + // (viewDidAppear や画面回転のたびに呼ばれる)を経由するたびに + // 直前の実行ボタンがリークしてしまう。 + ViewMonitor.stop() + weak var leaked: MonitorLauncherButton? + + // ローカル変数(window / launcher)を内側の関数に閉じ込め、 + // 弱参照だけを外側に残す。UIKit のオブジェクトは autoreleasepool 越しに + // 解放されることがあるため、確認前に明示的にプールを回す。 + func attachAndToggleOnce() throws { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + let launcher = try #require(window.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + leaked = launcher + // onToggle が実際にタップされてクロージャ内のキャプチャが働いた状態を再現する。 + launcher.onToggle?(true) + launcher.onToggle?(false) + } + try autoreleasepool { + try attachAndToggleOnce() + } + + // 画面遷移相当: addLauncherButton() が removeLauncherButton() 経由で + // 直前のボタンを新しいボタンに差し替える。removeFromSuperview() 自体が + // 内部で autorelease するオブジェクトを生む可能性があるため、 + // この呼び出しも autoreleasepool で包んでから弱参照を確認する。 + autoreleasepool { + let nextWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: nextWindow) + } + + #expect(leaked == nil) + + ViewMonitor.stop() + } } From 2e7cc092e804ba3e79b834360626ecf493d1b390 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 12:23:00 +0900 Subject: [PATCH 16/23] fix(ui): stop treating own InfoView appearance as a screen transition InfoView now renders through a UIHostingController, whose viewDidAppear fires when the overlay is shown. The swizzled hook mistook it for an app screen transition and called reload(), which tore down the overlay that had just been presented and replaced the launcher with a fresh OFF instance - on device the toggle appeared to never turn on. Mark ViewMonitor's internal hosting controller with MonitorInternalViewController and skip transition detection for it. Excluding UIHostingController as a whole would also swallow real transitions in SwiftUI apps, so only ViewMonitor's own controllers are excluded. Co-Authored-By: Claude Fable 5 --- .../UIViewController+MonitorSwizzling.swift | 7 +++ Sources/ViewMonitor/UI/InfoView.swift | 6 ++- .../UI/MonitorHostingController.swift | 24 ++++++++++ .../ViewMonitorLifecycleTests.swift | 48 +++++++++++++++++-- 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 Sources/ViewMonitor/UI/MonitorHostingController.swift diff --git a/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift b/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift index 7761846..468ce47 100644 --- a/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift +++ b/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift @@ -58,6 +58,13 @@ extension UIViewController { func viewMonitor_viewDidAppear(animated: Bool) { // 入れ替え後はこの呼び出しが元の viewDidAppear を指す。 viewMonitor_viewDidAppear(animated: animated) + // ViewMonitor 自身の UI(InfoView が内包するホスティングコントローラ)も + // viewDidAppear を発火させる。これを画面遷移として扱うと reload() が走り、 + // いま表示したばかりのオーバーレイを畳んだうえに、実行ボタンまで OFF の + // 新品へ差し替えてしまう(「トグルを押しても ON にならない」症状)。 + guard !(self is MonitorInternalViewController) else { + return + } ViewMonitor.detectedViewDidAppear() } } diff --git a/Sources/ViewMonitor/UI/InfoView.swift b/Sources/ViewMonitor/UI/InfoView.swift index 4ca4226..4fab089 100644 --- a/Sources/ViewMonitor/UI/InfoView.swift +++ b/Sources/ViewMonitor/UI/InfoView.swift @@ -13,7 +13,11 @@ final class InfoView: UIView { /// 表示幅。高さは行数に応じて `update(rows:)` が決める。 static let width: CGFloat = 200.0 - private let hostingController = UIHostingController(rootView: InfoRowsView(rows: [])) + /// ViewMonitor 内部の VC だと判別できる専用型を使う。素の + /// `UIHostingController` だと、この VC が発火させる `viewDidAppear` を + /// swizzling がアプリの画面遷移と誤認し、表示直後のオーバーレイを + /// `reload()` が畳んでしまう。 + private let hostingController = MonitorHostingController(rootView: InfoRowsView(rows: [])) /// 現在表示中の行。表示内容の検証用。 var displayedRows: [InfoRow] { hostingController.rootView.rows } diff --git a/Sources/ViewMonitor/UI/MonitorHostingController.swift b/Sources/ViewMonitor/UI/MonitorHostingController.swift new file mode 100644 index 0000000..050c9b5 --- /dev/null +++ b/Sources/ViewMonitor/UI/MonitorHostingController.swift @@ -0,0 +1,24 @@ +// +// MonitorHostingController.swift +// ViewMonitor +// + +import SwiftUI +import UIKit + +/// ViewMonitor 自身が内部で使うビューコントローラの目印。 +/// +/// ViewMonitor は `viewDidAppear` の swizzling でアプリの画面遷移を検知するが、 +/// 自前の UI が発火させた `viewDidAppear` まで画面遷移として扱うと、表示した +/// ばかりのオーバーレイを自分で畳んでしまう。この目印が付いたビューコントローラは +/// 検知対象から外す。 +/// +/// `UIHostingController` 自体を目印にはできない。アプリ側の SwiftUI 画面まで +/// 巻き込み、本来検知すべき画面遷移を取りこぼすため。 +@MainActor +protocol MonitorInternalViewController: UIViewController {} + +/// InfoView の描画に使うホスティングコントローラ。 +/// ViewMonitor 内部のものだと判別できるよう専用の型にしている。 +final class MonitorHostingController: + UIHostingController, MonitorInternalViewController {} diff --git a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift index c450a1a..466c030 100644 --- a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift +++ b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift @@ -45,11 +45,10 @@ struct ViewMonitorLifecycleTests { // ボタンに前面を奪われ、停止タップやドラッグが要素側に吸われていた。 // // ViewMonitor.start() は呼ばない: simulateLauncherButtonAttachedForTesting は - // started 状態に依存せず実行ボタンを取り付けられる。start() を呼ぶと - // viewDidAppear の swizzling が有効化され、addInfoView() 内で - // UIHostingController.sizeThatFits が誘発する viewDidAppear が - // 無関係な reload() を引き起こしてしまう(このテストバンドルには - // 接続中の window scene が無いため reload() が実行ボタンを再アタッチできない)。 + // started 状態に依存せず実行ボタンを取り付けられるため、この検証には不要。 + // (かつては addInfoView() 内の UIHostingController が誘発する viewDidAppear で + // reload() が走ってしまうため start() を避けていたが、内部 VC を + // MonitorInternalViewController として検知対象から外したので解消済み。) ViewMonitor.stop() let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) @@ -81,6 +80,45 @@ struct ViewMonitorLifecycleTests { launcher.onToggle?(false) } + @Test("自前の InfoView が発火させる viewDidAppear で計測状態が畳まれない") + func internalHostingControllerAppearanceKeepsMonitoringOn() throws { + // InfoView は描画を UIHostingController に委譲しているため、overlay を + // 表示すると自身の viewDidAppear が発火する。これを画面遷移として扱うと + // reload() が走り、いま開いたばかりのオーバーレイを自分で畳んだうえに + // 実行ボタンまで OFF の新品へ差し替えてしまう(実機では「トグルを押しても + // ON にならない」という症状になる)。自前の VC は検知対象から外す。 + ViewMonitor.stop() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + // swizzling を有効にするため start() を通す。 + ViewMonitor.start() + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + let launcher = try #require(window.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + + launcher.isSelected = true + launcher.onToggle?(true) + let infoView = try #require(window.subviews.compactMap { $0 as? InfoView }.first) + + // InfoView が内包するホスティングコントローラを responder chain から取り出す。 + // SwiftUI が中間レスポンダ(UIKitKeyPressResponder)を挟むため、 + // next を1つ辿るだけでは届かない。最初の UIViewController まで遡る。 + let hostedView = try #require(infoView.subviews.first) + var responder: UIResponder? = hostedView.next + while let current = responder, !(current is UIViewController) { + responder = current.next + } + let hostingController = try #require(responder as? UIViewController) + hostingController.viewDidAppear(false) + + // オーバーレイも実行ボタンもそのままであること。 + #expect(window.subviews.contains { $0 === infoView }) + #expect(window.subviews.contains { $0 === launcher }) + #expect(launcher.isSelected) + + launcher.onToggle?(false) + ViewMonitor.stop() + } + @Test("画面遷移で実行ボタンが差し替わると直前のボタンが解放される") func replacedLauncherButtonIsDeallocated() throws { // onToggle クロージャが自身の button を強参照キャプチャすると From 1ee0ee5716e0bd66e2502aa832839861dc82ad52 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 12:30:55 +0900 Subject: [PATCH 17/23] feat: enable SwiftUI element detection without an accessibility client iOS builds the accessibility tree only while an accessibility client is attached, so SwiftUI detection silently found nothing in normal runs. The README workaround (simctl defaults write AutomationEnabled) turned out not to work at all: the tree stays empty even after a simulator reboot with the key set. Add ViewMonitor.enableSwiftUIElementDetection(), which starts tree construction in-process the same way UI test runners do (_AXSSetAutomationEnabled). The implementation is compiled only into DEBUG builds; release builds get a no-op returning false and contain no private-API symbol strings. The SwiftUI example app now calls it at startup, and measurement buttons appear over all SwiftUI elements on a clean simulator with no external tooling. This also unblocks the end-to-end test that was written and deleted earlier in this branch because the tree never materialized in the unit-test host: with in-process activation it materializes immediately, so the scanner is now covered against a real UIHostingController tree instead of only fakes. Co-Authored-By: Claude Fable 5 --- .../ViewMonitorSwiftUIExampleApp.swift | 9 +++ .../Support/AccessibilityActivator.swift | 36 ++++++++++++ Sources/ViewMonitor/ViewMonitor.swift | 22 ++++++++ .../SwiftUIElementDetectionTests.swift | 55 +++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 Sources/ViewMonitor/Support/AccessibilityActivator.swift create mode 100644 Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift index 1a5a248..a56b3b1 100644 --- a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift @@ -3,6 +3,15 @@ import ViewMonitor @main struct ViewMonitorSwiftUIExampleApp: App { + + init() { + // SwiftUI 要素の検出はアクセシビリティツリーに依存し、iOS は + // アクセシビリティクライアント接続中しかツリーを構築しない。 + // DEBUG ビルド限定の API でプロセス内から構築を有効化する + // (リリースビルドでは何もしない)。 + ViewMonitor.enableSwiftUIElementDetection() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/Sources/ViewMonitor/Support/AccessibilityActivator.swift b/Sources/ViewMonitor/Support/AccessibilityActivator.swift new file mode 100644 index 0000000..a2decb3 --- /dev/null +++ b/Sources/ViewMonitor/Support/AccessibilityActivator.swift @@ -0,0 +1,36 @@ +// +// AccessibilityActivator.swift +// ViewMonitor +// + +#if DEBUG +import UIKit + +/// プロセス内でアクセシビリティランタイムを有効化する。 +/// +/// iOS はアクセシビリティクライアント(VoiceOver / Accessibility Inspector / +/// UI テスト)が接続している間しかアクセシビリティツリーを構築しないため、 +/// 素の状態では SwiftUI 要素の検出結果が常に空になる。ここで使う +/// `_AXSSetAutomationEnabled` は UI テストランナーが使うのと同じ仕組みで、 +/// クライアント接続なしにツリーの構築を開始させる。 +/// +/// プライベート API のため DEBUG ビルドでのみコンパイルする。`#if DEBUG` で +/// ファイルごと除外することで、リリースビルドのバイナリにはシンボル名の +/// 文字列自体が残らない。 +@MainActor +enum AccessibilityActivator { + + /// 有効化に成功したら true。シンボルが見つからない環境では false。 + /// dlopen のハンドルは閉じない(システムライブラリはプロセス存続中 + /// 常駐するため、解放する意味がない)。 + static func activate() -> Bool { + guard let handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW), + let symbol = dlsym(handle, "_AXSSetAutomationEnabled") else { + return false + } + typealias SetAutomationEnabled = @convention(c) (Bool) -> Void + unsafeBitCast(symbol, to: SetAutomationEnabled.self)(true) + return true + } +} +#endif diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index 87b3935..1b47be8 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -31,6 +31,28 @@ public final class ViewMonitor: NSObject { shared.reload() } + /// SwiftUI 要素の検出を、外部のアクセシビリティクライアント無しで有効にする。 + /// + /// iOS はアクセシビリティクライアント(VoiceOver / Accessibility Inspector / + /// UI テスト)が接続している間しかアクセシビリティツリーを構築しないため、 + /// 何もしないと SwiftUI 要素は検出されない。このメソッドはプロセス内から + /// ツリーの構築を有効化する。`ViewMonitor.start()` の前後どちらで呼んでもよい。 + /// + /// 内部でプライベート API を使うため **DEBUG ビルド限定**。リリースビルドでは + /// 実装ごとコンパイルから除外され、常に false を返す何もしないメソッドになる + /// (バイナリにプライベート API のシンボル名文字列も残らない)。 + /// + /// - Returns: 有効化できたら true。リリースビルドと、シンボルを解決できない + /// 環境では false。 + @discardableResult + public static func enableSwiftUIElementDetection() -> Bool { + #if DEBUG + return AccessibilityActivator.activate() + #else + return false + #endif + } + /// 計測を終了し、追加した表示をすべて取り除く。 public static func stop() { guard shared.started else { diff --git a/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift b/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift new file mode 100644 index 0000000..764e86c --- /dev/null +++ b/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift @@ -0,0 +1,55 @@ +import SwiftUI +import Testing +import UIKit +@testable import ViewMonitor + +/// 実物の `UIHostingController` が公開するアクセシビリティツリーに対する +/// エンドツーエンドの検出テスト。 +/// +/// iOS はアクセシビリティクライアントが接続している間しかツリーを構築しない +/// ため、素のユニットテスト環境ではこの検証ができなかった(過去に同種のテストが +/// 2連続で要素0件になり削除されている)。`enableSwiftUIElementDetection()` で +/// プロセス内からツリー構築を有効化できるようになったので、フェイクではなく +/// SwiftUI が実際に生成する要素で検出経路全体を検証する。 +@Suite("SwiftUI element detection (real accessibility tree)", .serialized) +@MainActor +struct SwiftUIElementDetectionTests { + + @Test("有効化後、実物のホスティングビューから Text / Image / Button が検出される") + func detectsRealSwiftUIElements() async throws { + // DEBUG ビルド(テストは常に該当)で有効化できること自体も検証対象。 + try #require(ViewMonitor.enableSwiftUIElementDetection()) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + let host = UIHostingController(rootView: VStack { + Text("Hello, ViewMonitor!") + Image(systemName: "viewfinder") + Button("Tap me") {} + }) + window.rootViewController = host + window.makeKeyAndVisible() + window.layoutIfNeeded() + + // ツリーは有効化後に非同期で構築されるため、時間切れまでポーリングする。 + // 固定スリープ1回だと CI の遅い環境で不安定になる。 + let scanner = ViewHierarchyScanner() + var kinds: Set = [] + for _ in 0..<100 where !kinds.isSuperset(of: ["Text", "Image", "Button"]) { + kinds = Set( + scanner.measurementTargets(in: window).compactMap { target in + if case .accessibilityElement(let info) = target { + return info.kind + } + return nil + } + ) + if kinds.isSuperset(of: ["Text", "Image", "Button"]) { + break + } + try await Task.sleep(nanoseconds: 100_000_000) + } + + #expect(kinds.isSuperset(of: ["Text", "Image", "Button"])) + window.isHidden = true + } +} From ca666944861165c597605b3fd64a5e1ae9eedfca Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 12:30:55 +0900 Subject: [PATCH 18/23] feat(ui): surface a notice when SwiftUI elements cannot be detected When the scanner finds a hosting view but zero accessibility elements, the overlay used to show nothing at all, which is indistinguishable from a bug (and was reported as one). Show instructions in the InfoView instead: call enableSwiftUIElementDetection() or attach an accessibility client. Pure UIKit screens are unaffected. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/InfoRowBuilder.swift | 15 ++++ .../Core/ViewHierarchyScanner.swift | 14 ++++ Sources/ViewMonitor/UI/MonitorOverlay.swift | 22 +++++- .../MonitorOverlayNoticeTests.swift | 68 +++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 Tests/ViewMonitorTests/MonitorOverlayNoticeTests.swift diff --git a/Sources/ViewMonitor/Core/InfoRowBuilder.swift b/Sources/ViewMonitor/Core/InfoRowBuilder.swift index 1db0e03..bede5af 100644 --- a/Sources/ViewMonitor/Core/InfoRowBuilder.swift +++ b/Sources/ViewMonitor/Core/InfoRowBuilder.swift @@ -49,6 +49,21 @@ enum InfoRowBuilder { return rows } + /// SwiftUI 要素が検出できない状態(ホスティングビューはあるのに + /// アクセシビリティ要素が1つも取れない)を知らせる案内行。 + /// iOS はアクセシビリティクライアント接続中しかツリーを構築しないため、 + /// 無言のままだと利用者には不具合と区別がつかない。 + static func swiftUIDetectionUnavailableRows() -> [InfoRow] { + [ + InfoRow(title: "SwiftUI", value: "elements not detected"), + InfoRow( + title: "fix", + value: "call ViewMonitor.enableSwiftUIElementDetection() in a debug build, " + + "or attach Accessibility Inspector / VoiceOver, then toggle again" + ) + ] + } + /// vs 行と、矩形関係に応じた距離行。 /// 分離時に投影が重なっている軸(nil)は行を出さない。 private static func distanceRows( diff --git a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift index 41c3511..ddb53d4 100644 --- a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift +++ b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift @@ -48,6 +48,20 @@ struct ViewHierarchyScanner { return result } + /// `root` を含む階層にホスティングビューがあるか。 + /// SwiftUI 要素が「検出できて0件」なのか「そもそも SwiftUI が無い」のかを + /// 呼び出し側が区別するために使う。除外規則は measurementTargets と揃える。 + @MainActor + func hasHostingView(in root: UIView) -> Bool { + guard !isRejected(root) else { + return false + } + if isHostingView(root) { + return true + } + return root.subviews.contains { hasHostingView(in: $0) } + } + /// `root` を含む階層から計測対象の UIKit ビューだけを集める。 @MainActor func targets(in root: UIView) -> [UIView] { diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index da43e75..b18ea6e 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -44,9 +44,11 @@ final class MonitorOverlay: NSObject { hide() self.rootView = rootView addInfoView(to: rootView) - for target in scanner.measurementTargets(in: rootView) { + let targets = scanner.measurementTargets(in: rootView) + for target in targets { addMonitorButton(for: target, rootView: rootView) } + showAccessibilityNoticeIfNeeded(for: targets, rootView: rootView) // SwiftUI 要素のボタンは rootView に直接addSubviewするため、 // infoView より後に追加されると重なり順で上に乗ってしまう。 // ドラッグ用ジェスチャの奪い合いを防ぐため、追加後に最前面へ戻す。 @@ -55,6 +57,24 @@ final class MonitorOverlay: NSObject { } } + /// ホスティングビューがあるのにアクセシビリティ要素を1つも検出できなかった + /// 場合、InfoView に案内を出す。iOS はアクセシビリティクライアント接続中しか + /// ツリーを構築しないため、この状態は珍しくない。無言のままだと利用者には + /// 不具合と区別がつかない(実際に「トグルが効かない」と報告された)。 + private func showAccessibilityNoticeIfNeeded(for targets: [MeasurementTarget], rootView: UIView) { + let detectedAccessibilityElement = targets.contains { target in + if case .accessibilityElement = target { + return true + } + return false + } + guard !detectedAccessibilityElement, scanner.hasHostingView(in: rootView), let infoView else { + return + } + infoView.update(rows: InfoRowBuilder.swiftUIDetectionUnavailableRows()) + infoView.isHidden = false + } + /// オーバーレイを取り除き、変更したビューの状態を元に戻す。 func hide() { buttons.forEach { $0.removeFromSuperview() } diff --git a/Tests/ViewMonitorTests/MonitorOverlayNoticeTests.swift b/Tests/ViewMonitorTests/MonitorOverlayNoticeTests.swift new file mode 100644 index 0000000..f1a514a --- /dev/null +++ b/Tests/ViewMonitorTests/MonitorOverlayNoticeTests.swift @@ -0,0 +1,68 @@ +import Testing +import UIKit +@testable import ViewMonitor + +/// SwiftUI 要素が検出できないときの案内表示。 +/// iOS はアクセシビリティクライアントが接続している間しかツリーを構築しない +/// ため、素の状態では SwiftUI 要素の検出結果が空になる。無言で0件のままだと +/// 利用者には不具合と区別がつかない(実際に「トグルが効かない」と報告された)。 +@Suite("MonitorOverlay accessibility notice") +@MainActor +struct MonitorOverlayNoticeTests { + + private func makeWindow() -> UIWindow { + UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + } + + private func infoView(in root: UIView) -> InfoView? { + root.subviews.compactMap { $0 as? InfoView }.first + } + + private func makeOverlay(hostingProbe: UIView) -> MonitorOverlay { + MonitorOverlay(scanner: ViewHierarchyScanner(isHostingView: { $0 === hostingProbe })) + } + + @Test("ホスティングビューはあるのに AX 要素が0件なら InfoView に案内が出る") + func showsNoticeWhenAccessibilityTreeIsEmpty() throws { + let window = makeWindow() + let host = UIView() + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + + overlay.show(on: window) + + let info = try #require(infoView(in: window)) + #expect(!info.isHidden) + #expect(info.displayedRows.contains { $0.value.contains("not detected") }) + } + + @Test("AX 要素が検出できていれば案内は出ず InfoView は隠れたまま") + func noNoticeWhenElementsAreDetected() throws { + let window = makeWindow() + let host = UIView() + let element = UIAccessibilityElement(accessibilityContainer: host) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + element.accessibilityFrame = CGRect(x: 10, y: 10, width: 50, height: 20) + host.accessibilityElements = [element] + window.addSubview(host) + let overlay = makeOverlay(hostingProbe: host) + + overlay.show(on: window) + + let info = try #require(infoView(in: window)) + #expect(info.isHidden) + } + + @Test("ホスティングビューが無い純 UIKit 画面では案内は出ない") + func noNoticeWithoutHostingView() throws { + let window = makeWindow() + window.addSubview(UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 20))) + let overlay = MonitorOverlay() + + overlay.show(on: window) + + let info = try #require(infoView(in: window)) + #expect(info.isHidden) + } +} From df41174264885bf1e23443c3dd7e745278a05b2d Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 12:30:55 +0900 Subject: [PATCH 19/23] docs: replace broken simulator workaround with the supported setup The documented simctl defaults write AutomationEnabled workaround does not actually build the accessibility tree (verified on iOS 26 simulator, including with a reboot). Document enableSwiftUIElementDetection() as the primary path, keep Accessibility Inspector / VoiceOver as the alternative, and cover both example apps in the device signing setup. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +++- README.md | 39 +++++++++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1397812..6aa7e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- SwiftUI 対応。SwiftUI 画面の `Text` / `Image` / `Button` を、SwiftUI が VoiceOver 向けに公開するアクセシビリティ要素経由で検出し、位置・サイズ・テキスト内容の計測と UIKit ビューとの距離計測に対応。検出にはアクセシビリティクライアント(VoiceOver / Accessibility Inspector など)が有効である必要がある。詳細は README を参照 +- SwiftUI 対応。SwiftUI 画面の `Text` / `Image` / `Button` を、SwiftUI が VoiceOver 向けに公開するアクセシビリティ要素経由で検出し、位置・サイズ・テキスト内容の計測と UIKit ビューとの距離計測に対応。検出にはアクセシビリティツリーの構築が必要(下記 `enableSwiftUIElementDetection()` か、VoiceOver / Accessibility Inspector などのクライアント接続)。詳細は README を参照 +- `ViewMonitor.enableSwiftUIElementDetection()`。外部のアクセシビリティクライアント無しで SwiftUI 要素の検出を有効化する。DEBUG ビルド限定(内部でプライベート API を使うため、リリースビルドでは実装ごとコンパイルから除外され常に false を返す) - SwiftUI ライフサイクルのアプリから起動するための `View.viewMonitor()` モディファイア - SwiftUI サンプルアプリ(`Example/ViewMonitorSwiftUIExample`) +- ホスティングビューがあるのにアクセシビリティ要素を検出できない場合、InfoView に有効化手順の案内を表示(無言で0件のままにしない) ### Changed diff --git a/README.md b/README.md index 7b1f4dd..0c388e9 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ import ViewMonitor @main struct MyApp: App { + init() { + ViewMonitor.enableSwiftUIElementDetection() + } + var body: some Scene { WindowGroup { ContentView() @@ -102,16 +106,26 @@ accessibility elements SwiftUI publishes, so measurement works without any changes to your views. A SwiftUI sample project is available at `Example/ViewMonitorSwiftUIExample`. +iOS builds the accessibility tree only while an accessibility client is +active, so without further setup no monitor buttons appear over SwiftUI +views. Enable detection in one of these ways: + +- Call `ViewMonitor.enableSwiftUIElementDetection()` once at startup, as in + the snippet above (before or after `start()`, either is fine). This works + in **debug builds only**: it relies on a private accessibility API + internally, so in release builds the implementation is compiled out — + the call is a no-op that returns `false`, and no private-API symbol + names remain in the binary. +- Alternatively, attach Xcode's Accessibility Inspector (Xcode > Open + Developer Tool > Accessibility Inspector) or enable VoiceOver, then + reopen the screen. + +When ViewMonitor finds SwiftUI content but no accessibility elements, the +info panel shows a notice with these instructions instead of failing +silently. + Known limitations for SwiftUI elements: -- SwiftUI elements are detected through the accessibility tree, which iOS - builds only while an accessibility client is active (VoiceOver, UI tests, - or Xcode's Accessibility Inspector). If no monitor buttons appear over - SwiftUI views, attach Accessibility Inspector (Xcode > Open Developer - Tool > Accessibility Inspector) or enable VoiceOver, then reopen the - screen. On the simulator you can also run - `xcrun simctl spawn booted defaults write com.apple.Accessibility AutomationEnabled 1` - before launching the app. - Measured values are limited to position, size, and text content (font / background / cornerRadius show `None`). - Monitor buttons do not follow scrolling; they refresh on screen @@ -120,14 +134,15 @@ Known limitations for SwiftUI elements: measured as a single element, and `.accessibilityHidden(true)` views are not detected. -### Running the sample on a device +### Running the samples on a device -The sample runs on the simulator as-is. To run it on a physical device, code -signing needs your own team, so create a `Local.xcconfig` (git-ignored) with -your Team ID before hitting Run: +The samples run on the simulator as-is. To run one on a physical device, +code signing needs your own team, so create a `Local.xcconfig` (git-ignored) +next to the example you want to run, with your Team ID, before hitting Run: ```sh echo 'DEVELOPMENT_TEAM = XXXXXXXXXX' > Example/ViewMonitorExample/Local.xcconfig +echo 'DEVELOPMENT_TEAM = XXXXXXXXXX' > Example/ViewMonitorSwiftUIExample/Local.xcconfig ``` ## Author From 7fcf8cd92cce2adb68582a8178bdf50d1b4c3b4d Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 13:18:12 +0900 Subject: [PATCH 20/23] fix: detect SwiftUI List rows by scanning the whole hosting subtree SwiftUI's List inserts a UICollectionView between the hosting view and the row content, and each row's accessibility elements are published by a CellHostingView inside the cell - a class that does not match the _UIHostingView prefix. Reading only the hosting view's own accessibilityElements therefore found no rows at all: the hosting view's array contains just the collection view (a UIView, skipped to avoid double detection, with no AX children of its own to recurse into). Once a hosting view is entered, read accessibility elements from every view in its subtree instead. Pure UIKit hierarchies keep the previous behavior (no accessibility scan), and elements published by more than one view are deduplicated by identity. Covered by a structural unit test mirroring the List layout and an end-to-end test against a real List in a UIHostingController; rows are measured one element per row, matching how List publishes them. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + .../Core/ViewHierarchyScanner.swift | 35 ++++++++-- .../SwiftUIElementDetectionTests.swift | 36 +++++++++++ .../ViewHierarchyScannerTests.swift | 64 +++++++++++++++++++ 4 files changed, 131 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa7e9a..a450b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SwiftUI ライフサイクルのアプリから起動するための `View.viewMonitor()` モディファイア - SwiftUI サンプルアプリ(`Example/ViewMonitorSwiftUIExample`) - ホスティングビューがあるのにアクセシビリティ要素を検出できない場合、InfoView に有効化手順の案内を表示(無言で0件のままにしない) +- `List` の行の検出。List はホスティングビューの下に UICollectionView を挟み、行のアクセシビリティ要素は各セル内のビューが公開するため、ホスティングビュー配下のすべてのビューを走査対象にした(行は List の仕様どおり1行=1要素として計測される) ### Changed diff --git a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift index ddb53d4..bfbd4ad 100644 --- a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift +++ b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift @@ -30,6 +30,23 @@ struct ViewHierarchyScanner { /// 除外対象のビューに達した時点で、その子孫は走査しない。 @MainActor func measurementTargets(in root: UIView) -> [MeasurementTarget] { + var seenElements = Set() + return collectTargets(in: root, underHostingView: false, seenElements: &seenElements) + } + + /// SwiftUI の List はホスティングビューの下に UICollectionView を挟み、 + /// 行の AX 要素は各セル内の CellHostingView(クラス名が `_UIHostingView` + /// 接頭辞に一致しない)が公開する。ホスティングビュー直属の配列だけを + /// 読むと行が1つも検出されないため、ホスティングビュー配下に入ったら + /// すべてのビューの AX 要素を読む。ホスティングビューを含まない + /// 純 UIKit の階層では従来どおり AX 走査を行わない。 + /// 同じ要素が複数のビューから公開された場合に備え、要素単位で重複を除く。 + @MainActor + private func collectTargets( + in root: UIView, + underHostingView: Bool, + seenElements: inout Set + ) -> [MeasurementTarget] { guard !isRejected(root) else { return [] } @@ -37,13 +54,21 @@ struct ViewHierarchyScanner { if isTarget(root) { result.append(.uiKitView(root)) } - if isHostingView(root) { - result.append( - contentsOf: accessibilityScanner.targets(in: root).map { .accessibilityElement($0) } - ) + let inHostingSubtree = underHostingView || isHostingView(root) + if inHostingSubtree { + for info in accessibilityScanner.targets(in: root) { + guard let element = info.element, seenElements.insert(ObjectIdentifier(element)).inserted else { + continue + } + result.append(.accessibilityElement(info)) + } } for subview in root.subviews { - result.append(contentsOf: measurementTargets(in: subview)) + result.append( + contentsOf: collectTargets( + in: subview, underHostingView: inHostingSubtree, seenElements: &seenElements + ) + ) } return result } diff --git a/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift b/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift index 764e86c..8ff390c 100644 --- a/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift +++ b/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift @@ -52,4 +52,40 @@ struct SwiftUIElementDetectionTests { #expect(kinds.isSuperset(of: ["Text", "Image", "Button"])) window.isHidden = true } + + @Test("実物の List の行も検出される") + func detectsRowsInsideRealList() async throws { + // List はホスティングビューの下に UICollectionView を挟み、行の AX 要素は + // 各セル内の CellHostingView が公開する。ホスティングビュー直属の配列 + // だけを読む実装だと行が1つも検出されない(実機で報告された症状)。 + try #require(ViewMonitor.enableSwiftUIElementDetection()) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + let host = UIHostingController(rootView: List(0..<5, id: \.self) { index in + Text("Row \(index)") + }) + window.rootViewController = host + window.makeKeyAndVisible() + window.layoutIfNeeded() + + // セルの生成もツリーの構築も非同期のため、時間切れまでポーリングする。 + let scanner = ViewHierarchyScanner() + var rowTexts: [String] = [] + for _ in 0..<100 where rowTexts.count < 5 { + window.layoutIfNeeded() + rowTexts = scanner.measurementTargets(in: window).compactMap { target in + guard case .accessibilityElement(let info) = target, info.kind == "Text" else { + return nil + } + return (info.element?.accessibilityLabel).flatMap { $0.hasPrefix("Row ") ? $0 : nil } + } + if rowTexts.count >= 5 { + break + } + try await Task.sleep(nanoseconds: 100_000_000) + } + + #expect(rowTexts.count >= 5, "detected rows: \(rowTexts)") + window.isHidden = true + } } diff --git a/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift b/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift index 5a25698..48f6bac 100644 --- a/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift +++ b/Tests/ViewMonitorTests/ViewHierarchyScannerTests.swift @@ -156,6 +156,70 @@ struct ViewHierarchyScannerTests { #expect(elements[0].hostingView === hostingProbe) } + @Test("ホスティングビュー配下の非ホスティングビューが持つ AX 要素も集める") + func collectsAccessibilityTargetsFromHostingSubtree() { + // SwiftUI の List は + // _UIHostingView → UICollectionView → セル → CellHostingView(AX 要素を保持) + // という構造になり、行の AX 要素はホスティングビュー自身ではなく + // 配下の別ビューが公開する。ホスティングビュー直属の配列だけを読むと + // 行が1つも検出されない。 + let root = UIView() + let hostingProbe = UIView() + let collectionLike = UIView() + let cellContent = UIView() + let element = UIAccessibilityElement(accessibilityContainer: cellContent) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + cellContent.accessibilityElements = [element] + collectionLike.addSubview(cellContent) + hostingProbe.addSubview(collectionLike) + root.addSubview(hostingProbe) + let scanner = ViewHierarchyScanner(isHostingView: { $0 === hostingProbe }) + + let elements = scanner.measurementTargets(in: root).compactMap { target -> AccessibilityElementInfo? in + if case .accessibilityElement(let info) = target { return info } + return nil + } + #expect(elements.count == 1) + #expect(elements.first?.element === element) + } + + @Test("ホスティングビュー配下に無いビューの AX 要素は集めない") + func ignoresAccessibilityElementsOutsideHostingSubtree() { + // 純 UIKit 画面で accessibilityElements を公開しているビューまで + // 計測対象に変わらないよう、AX 走査はホスティングビュー配下に限る。 + let root = UIView() + let plain = UIView() + let element = UIAccessibilityElement(accessibilityContainer: plain) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + plain.accessibilityElements = [element] + root.addSubview(plain) + + #expect(scanner.measurementTargets(in: root).isEmpty) + } + + @Test("同じ AX 要素が複数のビューから公開されても1件として集める") + func deduplicatesAccessibilityElements() { + let root = UIView() + let hostingProbe = UIView() + let child = UIView() + let element = UIAccessibilityElement(accessibilityContainer: hostingProbe) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + hostingProbe.accessibilityElements = [element] + child.accessibilityElements = [element] + hostingProbe.addSubview(child) + root.addSubview(hostingProbe) + let scanner = ViewHierarchyScanner(isHostingView: { $0 === hostingProbe }) + + let elements = scanner.measurementTargets(in: root).filter { target in + if case .accessibilityElement = target { return true } + return false + } + #expect(elements.count == 1) + } + @Test("除外対象のホスティングビューは走査しない") func skipsRejectedHostingView() { let root = UIView() From 9533e13be3636f3ffb8d0ff881d6e981f5fd8967 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 13:51:06 +0900 Subject: [PATCH 21/23] feat(ui): block touches on the app while measuring is on Taps on areas not covered by a measurement button used to reach the app: actions fired, lists scrolled (leaving SwiftUI element buttons at stale positions), and any resulting screen transition made the reload teardown discard the overlay and reset the toggle to off. Insert a transparent shield between the app and the measurement UI while the overlay is shown. It absorbs app-bound touches; the info panel, SwiftUI element buttons, and the launcher sit above it, and touches aimed at UIKit-target measurement buttons (which live inside the app hierarchy, below the shield) are let through via hit-test requery with a re-entrancy guard. Toggling isHidden during hitTest is not an option: some internal UIKit hit-test paths re-enter the override without checking hidden, overflowing the stack (verified via crash report). To interact with the app (e.g. scroll further down a list), toggle off, move the screen, and toggle on again to re-scan. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + README.md | 8 ++ Sources/ViewMonitor/UI/MonitorOverlay.swift | 15 +++ .../ViewMonitor/UI/MonitorShieldView.swift | 50 ++++++++++ .../ViewMonitorTests/MonitorShieldTests.swift | 94 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 Sources/ViewMonitor/UI/MonitorShieldView.swift create mode 100644 Tests/ViewMonitorTests/MonitorShieldTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a450b5e..478a6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SwiftUI サンプルアプリ(`Example/ViewMonitorSwiftUIExample`) - ホスティングビューがあるのにアクセシビリティ要素を検出できない場合、InfoView に有効化手順の案内を表示(無言で0件のままにしない) - `List` の行の検出。List はホスティングビューの下に UICollectionView を挟み、行のアクセシビリティ要素は各セル内のビューが公開するため、ホスティングビュー配下のすべてのビューを走査対象にした(行は List の仕様どおり1行=1要素として計測される) +- 計測中はアプリ本体へのタッチ(タップ・スクロール・エッジスワイプ)を遮断。誤操作でアクションが発火したり、画面遷移で計測状態が破棄されたりしない。計測ボタン・InfoView・実行ボタンなど ViewMonitor 自身の UI だけが操作できる ### Changed diff --git a/README.md b/README.md index 0c388e9..087600c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { Once running, tap the button that appears in the top-right corner of the screen to start measuring. A working sample project is available at `Example/ViewMonitorExample`. +While measuring is ON, touches on the app itself are blocked so that +accidental taps cannot navigate away, fire actions, or scroll the content +(a screen transition would discard the measurement overlay). Only +ViewMonitor's own UI — the measurement buttons, the info panel, and the +toggle — receives touches. To interact with the app (e.g. scroll a list to +measure items further down), toggle OFF, move the screen, then toggle ON +again to re-scan. + ### SwiftUI For apps using the SwiftUI lifecycle (`@main App`), attach `.viewMonitor()` diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index b18ea6e..a93ca52 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -16,6 +16,7 @@ final class MonitorOverlay: NSObject { private weak var rootView: UIView? private var infoView: InfoView? + private var shieldView: MonitorShieldView? private var buttons: [MonitorButton] = [] /// 計測のために userInteractionEnabled を一時的に有効化したビュー。 /// hide() で元に戻す。 @@ -40,9 +41,12 @@ final class MonitorOverlay: NSObject { } /// オーバーレイを構築して表示する。 + /// 盾 → InfoView → 計測ボタンの順に追加し、盾が計測 UI の背面・ + /// アプリ本体の前面に入るようにする。 func show(on rootView: UIView) { hide() self.rootView = rootView + addShieldView(to: rootView) addInfoView(to: rootView) let targets = scanner.measurementTargets(in: rootView) for target in targets { @@ -81,12 +85,23 @@ final class MonitorOverlay: NSObject { buttons.removeAll() infoView?.removeFromSuperview() infoView = nil + shieldView?.removeFromSuperview() + shieldView = nil forcedInteractionViews.forEach { $0.isUserInteractionEnabled = false } forcedInteractionViews.removeAll() lastSelectedButton = nil rootView = nil } + /// アプリ本体へのタッチを遮る盾を、計測 UI より先(=背面)に入れる。 + private func addShieldView(to rootView: UIView) { + let shield = MonitorShieldView(frame: rootView.bounds) + shield.autoresizingMask = [.flexibleWidth, .flexibleHeight] + shield.backgroundColor = .clear + rootView.addSubview(shield) + shieldView = shield + } + private func addInfoView(to rootView: UIView) { let size = rootView.bounds.size let infoView = InfoView( diff --git a/Sources/ViewMonitor/UI/MonitorShieldView.swift b/Sources/ViewMonitor/UI/MonitorShieldView.swift new file mode 100644 index 0000000..5252f30 --- /dev/null +++ b/Sources/ViewMonitor/UI/MonitorShieldView.swift @@ -0,0 +1,50 @@ +// +// MonitorShieldView.swift +// ViewMonitor +// + +import UIKit + +/// 計測中にアプリ本体へのタッチを遮る透明ビュー。 +/// +/// 計測ボタンに覆われていない領域へのタップ・スクロール・エッジスワイプが +/// アプリ側に届くと、ボタンの誤発火だけでなく、画面遷移で計測状態ごと +/// 破棄されてしまう(遷移検知の reload が実行ボタンを OFF の新品に差し替える)。 +/// +/// rootView 直下・計測 UI より背面に挟み、アプリ本体宛てのタッチを吸収する。 +/// InfoView・SwiftUI 要素の計測ボタン・実行ボタンはこの盾より前面に居るため +/// 影響を受けない。UIKit 対象の計測ボタンだけは対象ビューの subview として +/// アプリ階層の内側(盾より背面)に追加されるので、ヒットテストで判別して通す。 +final class MonitorShieldView: UIView { + + /// 背面の引き直し中かどうか。 + /// isHidden を一時的に立てて自分を除外する方法は使えない。UIKit 内部の + /// ヒットテスト経路には hidden を確認せず hitTest を呼び直すものがあり、 + /// 無限再帰でスタックオーバーフローする(実測)。ヒットテスト中に + /// ビューの状態を変えること自体も CA の再計算を誘発するため避ける。 + private var isRequeryingBelow = false + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // 引き直しから再入されたときは自分を透明扱いにし、 + // 親の走査を盾より背面のビューへ進ませる。 + if isRequeryingBelow { + return nil + } + guard let superview else { + return super.hitTest(point, with: event) + } + // 背面を引き直し、UIKit 対象ビュー内の計測ボタン(盾より背面に居る) + // 宛てのタッチだけを通す。 + isRequeryingBelow = true + defer { + isRequeryingBelow = false + } + let below = superview.hitTest(convert(point, to: superview), with: event) + guard let below else { + return self + } + let isMonitorButton = sequence(first: below, next: { $0.superview }) + .contains { $0 is MonitorButton } + return isMonitorButton ? nil : self + } +} diff --git a/Tests/ViewMonitorTests/MonitorShieldTests.swift b/Tests/ViewMonitorTests/MonitorShieldTests.swift new file mode 100644 index 0000000..e103220 --- /dev/null +++ b/Tests/ViewMonitorTests/MonitorShieldTests.swift @@ -0,0 +1,94 @@ +import Testing +import UIKit +@testable import ViewMonitor + +/// 計測中のタッチ遮断。 +/// 計測ボタンに覆われていない領域へのタップやスクロールがアプリ側に届くと、 +/// ボタンの誤発火だけでなく、画面遷移で計測状態ごと破棄されてしまう +/// (遷移検知の reload が実行ボタンを OFF の新品に差し替える)。 +/// 計測中はアプリ本体へのタッチを遮り、ViewMonitor 自身の UI へのタッチ +/// だけを通す。 +@Suite("MonitorOverlay input shield") +@MainActor +struct MonitorShieldTests { + + private func makeWindow() -> UIWindow { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + // 生成直後の UIWindow は hidden で、hitTest が常に nil を返す。 + // ヒットテストの検証ができるよう表示状態にする(シーンへの取り付けは不要)。 + window.isHidden = false + return window + } + + private func shield(in root: UIView) -> UIView? { + root.subviews.first { $0 is MonitorShieldView } + } + + @Test("表示中はアプリへのタッチを遮る盾が入り、hide で取り除かれる") + func showAddsShieldAndHideRemovesIt() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + + overlay.show(on: window) + let shieldView = try #require(shield(in: window)) + #expect(shieldView.frame == window.bounds) + + overlay.hide() + #expect(shield(in: window) == nil) + } + + @Test("計測ボタンの無い領域へのタッチは盾が吸収する") + func shieldAbsorbsTouchesOverAppContent() throws { + let window = makeWindow() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + window.addSubview(label) + let overlay = MonitorOverlay() + overlay.show(on: window) + + // ラベル(計測ボタン)から離れた何もない領域。 + let hit = window.hitTest(CGPoint(x: 250, y: 400), with: nil) + + #expect(hit is MonitorShieldView) + overlay.hide() + } + + @Test("UIKit 対象ビュー内の計測ボタンへのタッチは盾を通り抜ける") + func shieldPassesTouchesToUIKitMonitorButtons() throws { + // UIKit 対象の計測ボタンは対象ビューの subview として追加されるため、 + // rootView 直下の盾より背面に居る。盾が素朴に全タッチを吸収すると + // UIKit 要素が一切計測できなくなる。 + let window = makeWindow() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + window.addSubview(label) + let overlay = MonitorOverlay() + overlay.show(on: window) + + let hit = try #require(window.hitTest(CGPoint(x: 50, y: 110), with: nil)) + + let hitsMonitorButton = sequence(first: hit, next: { $0.superview }) + .contains { $0 is MonitorButton } + #expect(hitsMonitorButton) + overlay.hide() + } + + @Test("SwiftUI 要素の計測ボタンと InfoView は盾より前面に居る") + func overlayUIStaysAboveShield() throws { + let window = makeWindow() + let host = UIView() + let element = UIAccessibilityElement(accessibilityContainer: host) + element.isAccessibilityElement = true + element.accessibilityTraits = .staticText + element.accessibilityFrame = CGRect(x: 40, y: 200, width: 100, height: 30) + host.accessibilityElements = [element] + window.addSubview(host) + let overlay = MonitorOverlay(scanner: ViewHierarchyScanner(isHostingView: { $0 === host })) + overlay.show(on: window) + + let shieldIndex = try #require(window.subviews.firstIndex { $0 is MonitorShieldView }) + let buttonIndex = try #require(window.subviews.firstIndex { $0 is MonitorButton }) + let infoIndex = try #require(window.subviews.firstIndex { $0 is InfoView }) + #expect(buttonIndex > shieldIndex) + #expect(infoIndex > shieldIndex) + overlay.hide() + } +} From 7c934295125383abed2123c475569f5789714651 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 14:46:37 +0900 Subject: [PATCH 22/23] fix(ui): attach all monitor buttons above the shield instead of inside targets On device, tapping a UIKit-target monitor button (nav bar labels) did nothing while measuring. The shield passed such touches through by re-running the hit test, but the requery traverses _UIHostingView.hitTest, which is not a pure function: for the same point it returned the monitor button on the first call and itself on the second (captured via on-device logging), so the final hit went to the shield and the button never received the touch. Stop relying on hit-testing through SwiftUI entirely: attach UIKit target buttons to the rootView above the shield, the same placement SwiftUI element buttons already use, with frames converted to window coordinates. The shield becomes a plain absorb-everything view (the requery and its re-entrancy guard are gone), and forcing isUserInteractionEnabled on target views is no longer needed. Since buttons are now fixed-position, targets that are effectively invisible (hidden or alpha 0, e.g. the inline nav title label while the large title is shown) get no button - previously their buttons were invisible together with the hidden parent, but at window level they would float over nothing. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + Sources/ViewMonitor/UI/MonitorOverlay.swift | 43 +++++++++++------ .../ViewMonitor/UI/MonitorShieldView.swift | 45 ++++-------------- Sources/ViewMonitor/ViewMonitor.swift | 2 +- .../MonitorOverlayTests.swift | 15 +++++- .../ViewMonitorTests/MonitorShieldTests.swift | 47 ++++++++++++++++--- 6 files changed, 94 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478a6a5..7ab4713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `UIHostingController` を埋め込んだ画面でも SwiftUI 要素に計測ボタンが表示されるように - `InfoView` の内部描画を SwiftUI 化(挙動変更なし) +- 計測ボタンは UIKit 対象・SwiftUI 要素とも rootView 直下の固定配置に統一(対象ビューの subview には追加しない)。SwiftUI のホスティングビューのヒットテストが非決定的で、対象ビュー内に置いたボタンへのタッチが実機で届かないことがあるため。あわせて、隠れている対象(大タイトル表示中のインラインナビタイトルなど)にはボタンを付けない ## [2.2.0] - 2026-08-06 diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index a93ca52..306644a 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -18,9 +18,6 @@ final class MonitorOverlay: NSObject { private var infoView: InfoView? private var shieldView: MonitorShieldView? private var buttons: [MonitorButton] = [] - /// 計測のために userInteractionEnabled を一時的に有効化したビュー。 - /// hide() で元に戻す。 - private var forcedInteractionViews: [UIView] = [] /// 直前に選択したボタン。距離計測の参照元。 /// weak なので hide() / 画面遷移の reload でボタンが破棄されれば /// 自動的に nil に戻り、古い画面のビューとペアになることがない。 @@ -53,8 +50,8 @@ final class MonitorOverlay: NSObject { addMonitorButton(for: target, rootView: rootView) } showAccessibilityNoticeIfNeeded(for: targets, rootView: rootView) - // SwiftUI 要素のボタンは rootView に直接addSubviewするため、 - // infoView より後に追加されると重なり順で上に乗ってしまう。 + // 計測ボタンは rootView に直接addSubviewするため、infoView より後に + // 追加されると重なり順で上に乗ってしまう。 // ドラッグ用ジェスチャの奪い合いを防ぐため、追加後に最前面へ戻す。 if let infoView { rootView.bringSubviewToFront(infoView) @@ -87,8 +84,6 @@ final class MonitorOverlay: NSObject { infoView = nil shieldView?.removeFromSuperview() shieldView = nil - forcedInteractionViews.forEach { $0.isUserInteractionEnabled = false } - forcedInteractionViews.removeAll() lastSelectedButton = nil rootView = nil } @@ -117,27 +112,47 @@ final class MonitorOverlay: NSObject { self.infoView = infoView } + /// 計測ボタンは UIKit 対象・SwiftUI 要素とも rootView 直下(盾より前面)に + /// 固定配置する。対象ビューの subview にすると盾との間に SwiftUI の + /// ホスティングビューが挟まり、その hitTest の非決定性でタッチが届かない + /// ことがある(MonitorShieldView のコメント参照)。 + /// ViewMonitor は keyWindow を rootView として渡すため window 座標 = rootView 座標。 private func addMonitorButton(for target: MeasurementTarget, rootView: UIView) { switch target { case .uiKitView(let view): - let button = makeMonitorButton(for: target, frame: CGRect(origin: .zero, size: view.frame.size)) - if !view.isUserInteractionEnabled { - forcedInteractionViews.append(view) - view.isUserInteractionEnabled = true + // ボタンは固定配置のため、隠れた対象(大タイトル表示中の + // インラインナビタイトルなど)にボタンを付けると、何もない場所に + // ボタンだけが浮いてしまう。不可視の対象には付けない。 + guard Self.isEffectivelyVisible(view) else { + return } - view.addSubview(button) + let frame = ViewInspector.inspect(view, in: currentWindow).frameInWindow + let button = makeMonitorButton(for: target, frame: frame) + rootView.addSubview(button) case .accessibilityElement(let info): guard let element = info.element else { return } - // ViewMonitor は keyWindow を rootView として渡すため window 座標 = rootView 座標。 - // 対象の UIView が存在しないので rootView 直下に固定配置する(スクロール非追従)。 let frame = ViewInspector.inspect(element: element, kind: info.kind, in: currentWindow).frameInWindow let button = makeMonitorButton(for: target, frame: frame) rootView.addSubview(button) } } + /// 対象が画面上で見えているか。自身または祖先が hidden か alpha ほぼ0なら不可視。 + /// ウィンドウ自体の可視性は判断に含めない(隠れたウィンドウでは + /// そもそも何も表示されず、計測対象の取捨には意味を持たないため)。 + static func isEffectivelyVisible(_ view: UIView) -> Bool { + var current: UIView? = view + while let view = current, !(view is UIWindow) { + if view.isHidden || view.alpha <= 0.01 { + return false + } + current = view.superview + } + return true + } + private func makeMonitorButton(for target: MeasurementTarget, frame: CGRect) -> MonitorButton { let button = MonitorButton(frame: frame) let color = UIColor(monitorHex: configuration.overlayColorHex, alpha: configuration.overlayAlpha) ?? .green diff --git a/Sources/ViewMonitor/UI/MonitorShieldView.swift b/Sources/ViewMonitor/UI/MonitorShieldView.swift index 5252f30..e2fc742 100644 --- a/Sources/ViewMonitor/UI/MonitorShieldView.swift +++ b/Sources/ViewMonitor/UI/MonitorShieldView.swift @@ -11,40 +11,11 @@ import UIKit /// アプリ側に届くと、ボタンの誤発火だけでなく、画面遷移で計測状態ごと /// 破棄されてしまう(遷移検知の reload が実行ボタンを OFF の新品に差し替える)。 /// -/// rootView 直下・計測 UI より背面に挟み、アプリ本体宛てのタッチを吸収する。 -/// InfoView・SwiftUI 要素の計測ボタン・実行ボタンはこの盾より前面に居るため -/// 影響を受けない。UIKit 対象の計測ボタンだけは対象ビューの subview として -/// アプリ階層の内側(盾より背面)に追加されるので、ヒットテストで判別して通す。 -final class MonitorShieldView: UIView { - - /// 背面の引き直し中かどうか。 - /// isHidden を一時的に立てて自分を除外する方法は使えない。UIKit 内部の - /// ヒットテスト経路には hidden を確認せず hitTest を呼び直すものがあり、 - /// 無限再帰でスタックオーバーフローする(実測)。ヒットテスト中に - /// ビューの状態を変えること自体も CA の再計算を誘発するため避ける。 - private var isRequeryingBelow = false - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // 引き直しから再入されたときは自分を透明扱いにし、 - // 親の走査を盾より背面のビューへ進ませる。 - if isRequeryingBelow { - return nil - } - guard let superview else { - return super.hitTest(point, with: event) - } - // 背面を引き直し、UIKit 対象ビュー内の計測ボタン(盾より背面に居る) - // 宛てのタッチだけを通す。 - isRequeryingBelow = true - defer { - isRequeryingBelow = false - } - let below = superview.hitTest(convert(point, to: superview), with: event) - guard let below else { - return self - } - let isMonitorButton = sequence(first: below, next: { $0.superview }) - .contains { $0 is MonitorButton } - return isMonitorButton ? nil : self - } -} +/// rootView 直下・計測 UI より背面に挟み、届いたタッチをすべて吸収する。 +/// 計測ボタン・InfoView・実行ボタンは必ずこの盾より前面(rootView 直下)に +/// 置くこと。背面のビューへ選択的にタッチを通す作りにはできない: +/// ヒットテストの引き直しは経路上の `_UIHostingView.hitTest`(SwiftUI 独自 +/// 実装)を通るが、これは純粋関数ではなく、同じ点への連続呼び出しで異なる +/// 結果を返すことがある(実機で1回目は配下の計測ボタン、2回目は自分自身を +/// 返し、タッチが盾に吸収された)。 +final class MonitorShieldView: UIView {} diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index 1b47be8..b580d48 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -99,7 +99,7 @@ public final class ViewMonitor: NSObject { } if isSelected { self.overlay.show(on: rootView) - // SwiftUI 要素のボタンは rootView に直接addSubviewされるため、 + // 計測ボタンは rootView に直接addSubviewされるため、 // show(on:) の後だと実行ボタンより前面に乗ってしまう。 // 実行ボタン(停止操作)がタップやドラッグを奪われないよう最前面に戻す。 if let button { diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index b6e1ece..f075ae0 100644 --- a/Tests/ViewMonitorTests/MonitorOverlayTests.swift +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -12,8 +12,21 @@ struct MonitorOverlayTests { UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) } + /// 計測ボタンは対象の種類によらず rootView(=window)直下に付く。 + /// `view` が対象の UIKit ビューならそのボタンを、`view` が window なら + /// SwiftUI 要素(アクセシビリティ要素)のボタンを返す。 private func monitorButton(on view: UIView) -> MonitorButton? { - view.subviews.compactMap { $0 as? MonitorButton }.first + let root = (view as? UIWindow) ?? view.window + return root?.subviews.compactMap { $0 as? MonitorButton }.first { button in + switch button.measurementTarget { + case .uiKitView(let target): + return target === view + case .accessibilityElement: + return view is UIWindow + case nil: + return false + } + } } private func infoView(in root: UIView) -> InfoView? { diff --git a/Tests/ViewMonitorTests/MonitorShieldTests.swift b/Tests/ViewMonitorTests/MonitorShieldTests.swift index e103220..b7d9241 100644 --- a/Tests/ViewMonitorTests/MonitorShieldTests.swift +++ b/Tests/ViewMonitorTests/MonitorShieldTests.swift @@ -52,25 +52,60 @@ struct MonitorShieldTests { overlay.hide() } - @Test("UIKit 対象ビュー内の計測ボタンへのタッチは盾を通り抜ける") - func shieldPassesTouchesToUIKitMonitorButtons() throws { - // UIKit 対象の計測ボタンは対象ビューの subview として追加されるため、 - // rootView 直下の盾より背面に居る。盾が素朴に全タッチを吸収すると - // UIKit 要素が一切計測できなくなる。 + @Test("UIKit 対象の計測ボタンも盾より前面に付き、タップが届く") + func uiKitMonitorButtonsSitAboveShield() throws { + // 対象ビューの subview としてボタンを付けると、盾との間に SwiftUI の + // ホスティングビューが挟まる。_UIHostingView の hitTest は純粋関数では + // なく、同じ点への連続呼び出しで異なる結果を返すことがある(実機で + // 1回目は配下の計測ボタン、2回目は自分自身を返し、タッチが盾に + // 吸収された)。ヒットテストで SwiftUI を経由しないよう、UIKit 対象の + // ボタンも rootView 直下・盾より前面に置く。 let window = makeWindow() let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) window.addSubview(label) let overlay = MonitorOverlay() overlay.show(on: window) - let hit = try #require(window.hitTest(CGPoint(x: 50, y: 110), with: nil)) + let shieldIndex = try #require(window.subviews.firstIndex { $0 is MonitorShieldView }) + let buttonIndex = try #require(window.subviews.firstIndex { $0 is MonitorButton }) + #expect(buttonIndex > shieldIndex) + let hit = try #require(window.hitTest(CGPoint(x: 50, y: 110), with: nil)) let hitsMonitorButton = sequence(first: hit, next: { $0.superview }) .contains { $0 is MonitorButton } #expect(hitsMonitorButton) overlay.hide() } + @Test("隠れているビューには計測ボタンを付けない") + func skipsInvisibleTargets() throws { + // ボタンは rootView 直下の固定配置になったため、隠れた親の内側に + // 居た頃と違い、対象が不可視でもボタンだけが画面に浮いてしまう + // (大タイトル表示中の インラインナビタイトル UILabel で実際に発生)。 + // 不可視の対象はボタン自体を付けない。 + let window = makeWindow() + let hiddenContainer = UIView(frame: CGRect(x: 0, y: 200, width: 200, height: 40)) + hiddenContainer.isHidden = true + let labelInHidden = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 20)) + hiddenContainer.addSubview(labelInHidden) + let transparentLabel = UILabel(frame: CGRect(x: 0, y: 300, width: 100, height: 20)) + transparentLabel.alpha = 0 + let visibleLabel = UILabel(frame: CGRect(x: 0, y: 400, width: 100, height: 20)) + window.addSubview(hiddenContainer) + window.addSubview(transparentLabel) + window.addSubview(visibleLabel) + let overlay = MonitorOverlay() + + overlay.show(on: window) + + let buttons = window.subviews.compactMap { $0 as? MonitorButton } + #expect(buttons.count == 1) + if case .uiKitView(let target) = buttons.first?.measurementTarget { + #expect(target === visibleLabel) + } + overlay.hide() + } + @Test("SwiftUI 要素の計測ボタンと InfoView は盾より前面に居る") func overlayUIStaysAboveShield() throws { let window = makeWindow() From fd616b940564e123ea15bd14b185823d426ebf0f Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 14:51:07 +0900 Subject: [PATCH 23/23] docs: describe pinned buttons and transition behavior accurately Monitor buttons are now fixed at window coordinates for UIKit targets too, so the scroll-following note no longer belongs to the SwiftUI limitations list. Fold it into the general measuring section, and correct the transition behavior: the overlay closes and the toggle returns to OFF, it does not refresh. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 087600c..2733579 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,10 @@ While measuring is ON, touches on the app itself are blocked so that accidental taps cannot navigate away, fire actions, or scroll the content (a screen transition would discard the measurement overlay). Only ViewMonitor's own UI — the measurement buttons, the info panel, and the -toggle — receives touches. To interact with the app (e.g. scroll a list to +toggle — receives touches. Measurement buttons are pinned at the positions +captured when the toggle was turned ON, and if the screen still changes +while measuring (e.g. a programmatic transition), the overlay closes and +the toggle returns to OFF. To interact with the app (e.g. scroll a list to measure items further down), toggle OFF, move the screen, then toggle ON again to re-scan. @@ -136,8 +139,6 @@ Known limitations for SwiftUI elements: - Measured values are limited to position, size, and text content (font / background / cornerRadius show `None`). -- Monitor buttons do not follow scrolling; they refresh on screen - transitions. - Views combined with `.accessibilityElement(children: .combine)` are measured as a single element, and `.accessibilityHidden(true)` views are not detected.