From 68ed53cb7818011e019af511a476663577b90c98 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Sun, 9 Aug 2026 22:24:57 +0900 Subject: [PATCH] fix: harden reload against no-op orientation changes, modals, and lost windows Follow-ups to the keep-measuring change, from a code review of Sources/ViewMonitor: - Skip the deferred orientation reload when the key window and its bounds are unchanged (faceUp/faceDown/upsideDown): the interface does not rotate there, and reloading wiped the selection, distance reference, and InfoView while the screen looked identical. Covered by a faceUp UI test that fails with the guard removed. - Coalesce deferred orientation reloads: one physical rotation emits several notifications, and each queued its own full teardown/re-scan. - End measuring when a transition comes from a modal presentation (alerts, sheets): keeping the shield would cover the dialog and make its buttons untappable. Detected via isBeingPresented in the swizzled hook; push transitions and rotation still keep measuring. Covered by lifecycle unit tests through a new transition seam. - Derive keep-measuring from launcherButton.isSelected instead of a stored flag. The flag could stay latched when a reload found no key window (armed shield with no visible UI re-appearing on a later transition) and could diverge from the button state in onToggle's guard path; derivation removes the state duplication entirely. - Extract beginMeasuring(with:on:) shared by onToggle and the restore path, removing the copy-pasted show sequence. - Pin the launcher to the top-right via autoresizing so that even a reload racing the window resize cannot strand it off screen. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 +++ .../ViewMonitorUITests.swift | 18 +++++ .../UIViewController+MonitorSwizzling.swift | 4 +- .../UI/MonitorLauncherButton.swift | 4 + Sources/ViewMonitor/ViewMonitor.swift | 77 ++++++++++++++----- .../ViewMonitorLifecycleTests.swift | 45 ++++++++++- 6 files changed, 137 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df3b4a6..cb098b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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] + +### Fixed + +- faceUp / faceDown など、インターフェイスが回転せずジオメトリが変わらない向きの変化のたびに reload が走り、選択状態(赤枠・距離計測の参照・InfoView の表示)が無言で破棄されていた。ジオメトリが変わらない場合は貼り直さない +- 計測中にアプリがモーダル(アラート・シート等)をプログラム提示すると、計測維持のシールドがダイアログの上に乗り、OK ボタン等が押せなくなっていた。モーダル提示の遷移では従来どおり計測を終了する +- 1回の物理回転で複数届く向き通知のたびに全再スキャンが走り、ちらつきや引っかかりの原因になっていた。貼り直しの予約を1つに集約 +- 貼り直し先のウィンドウが見つからないとき「計測中」状態だけが残留し、後の遷移で表示のないままシールドが復活し得た。計測中かどうかを実行ボタンの選択状態から導出する方式に変更し、状態の二重持ちを解消 +- 実行ボタンを右上に係留(autoresizing)。回転時の貼り直しが万一リサイズ完了前に走っても、ウィンドウのリサイズに追従して画面内に留まる + ## [2.4.0] - 2026-08-09 ### Added diff --git a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift index 5c4b3da..db9c685 100644 --- a/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests/ViewMonitorUITests.swift @@ -107,6 +107,24 @@ final class ViewMonitorUITests: XCTestCase { } } + func testFaceUpOrientationKeepsSelection() { + let app = XCUIApplication() + launchAndStartMeasuring(app) + + let hello = app.staticTexts["Hello, ViewMonitor!"] + XCTAssertTrue(hello.exists) + tapCenter(of: hello) + XCTAssertTrue(app.staticTexts["class: Text"].waitForExistence(timeout: 5)) + + // 端末を水平に置く(faceUp)。インターフェイスは回転せずジオメトリも + // 変わらないので、選択状態と InfoView は維持されるべき。 + XCUIDevice.shared.orientation = .faceUp + Thread.sleep(forTimeInterval: 1.5) + + XCTAssertTrue(app.staticTexts["class: Text"].exists, "faceUp で選択状態が破棄された") + XCTAssertTrue(app.buttons["ViewMonitor.launcher"].isSelected) + } + func testShieldBlocksAppInteractionWhileMeasuring() { let app = XCUIApplication() app.launch() diff --git a/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift b/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift index 468ce47..cae5f33 100644 --- a/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift +++ b/Sources/ViewMonitor/Support/UIViewController+MonitorSwizzling.swift @@ -65,6 +65,8 @@ extension UIViewController { guard !(self is MonitorInternalViewController) else { return } - ViewMonitor.detectedViewDidAppear() + // isBeingPresented は viewDidAppear の時点でまだ true になっている。 + // モーダル提示(アラート等)を計測 UI で覆わないための判定に使う。 + ViewMonitor.detectedViewDidAppear(isBeingPresented: isBeingPresented) } } diff --git a/Sources/ViewMonitor/UI/MonitorLauncherButton.swift b/Sources/ViewMonitor/UI/MonitorLauncherButton.swift index 94a1a18..718d3e3 100644 --- a/Sources/ViewMonitor/UI/MonitorLauncherButton.swift +++ b/Sources/ViewMonitor/UI/MonitorLauncherButton.swift @@ -27,6 +27,10 @@ final class MonitorLauncherButton: UIButton { super.init(frame: CGRect(origin: origin, size: Self.size)) // UI テスト(XCUITest)から実行ボタンを特定するための識別子。 accessibilityIdentifier = "ViewMonitor.launcher" + // 右上に係留する。回転の reload が万一リサイズ完了前の bounds で + // 配置しても、その後のウィンドウリサイズで右上に追従し、 + // 画面外に取り残されない(停止操作を失わない)ための保険。 + autoresizingMask = [.flexibleLeftMargin, .flexibleBottomMargin] setBackgroundImage(ViewMonitorAsset.button ?? .monitorSolidColor(.black), for: .normal) setBackgroundImage(ViewMonitorAsset.buttonSelected ?? .monitorSolidColor(.red), for: .selected) addTarget(self, action: #selector(toggle), for: .touchUpInside) diff --git a/Sources/ViewMonitor/ViewMonitor.swift b/Sources/ViewMonitor/ViewMonitor.swift index eb59d69..7fb9ab5 100644 --- a/Sources/ViewMonitor/ViewMonitor.swift +++ b/Sources/ViewMonitor/ViewMonitor.swift @@ -19,9 +19,9 @@ public final class ViewMonitor: NSObject { private var launcherButton: MonitorLauncherButton? private weak var rootView: UIView? private var started = false - /// 計測中(トグル ON)かどうか。実行ボタンの isSelected は reload() で - /// ボタンごと作り直されて消えるため、状態はここが持つ。 - private var measuring = false + /// 回転通知起因の reload が既に予約済みかどうか。1回の物理回転で + /// 通知が複数回届くため、予約は1つに束ねる。 + private var pendingOrientationReload = false /// 計測を開始する。実行ボタンが画面に表示される。 public static func start() { @@ -65,15 +65,26 @@ public final class ViewMonitor: NSObject { shared.removeLauncherButton() shared.stopObservingOrientation() shared.started = false - shared.measuring = false } /// `viewDidAppear` の swizzling から呼ばれる。 - static func detectedViewDidAppear() { + /// `isBeingPresented` はモーダル提示(アラート・シート等)による出現かどうか。 + static func detectedViewDidAppear(isBeingPresented: Bool = false) { guard shared.started else { return } - shared.reload() + shared.handleTransition(isBeingPresented: isBeingPresented, on: WindowProvider.keyWindow) + } + + /// 画面遷移・回転に応じてオーバーレイと実行ボタンを貼り直す。 + /// モーダル提示のときは計測を終了する: シールドで塞げない遷移のうち、 + /// モーダル(エラーダイアログ等)を覆ってしまうと、その OK ボタンが + /// 押せなくなるため。push 遷移や回転では計測を維持する。 + private func handleTransition(isBeingPresented: Bool, on newRootView: UIView?) { + if isBeingPresented { + launcherButton?.isSelected = false + } + reload(on: newRootView) } /// 画面が入れ替わったので、オーバーレイと実行ボタンを貼り直す。 @@ -85,6 +96,11 @@ public final class ViewMonitor: NSObject { /// 接続済みの window scene が無く `WindowProvider.keyWindow` が常に nil に /// なるため、貼り直し先を差し替えられるようにしている。 private func reload(on newRootView: UIView?) { + // 計測中かどうかは実行ボタンの isSelected から導出する。専用フラグを + // 持つと、貼り直し先が見つからず実行ボタンを失った後も「計測中」が + // 残り続け、後の遷移で何の表示も無いままシールドが復活してしまう。 + // ボタンはこの後作り直すので、先に読む。 + let keepMeasuring = launcherButton?.isSelected == true overlay.hide() removeLauncherButton() rootView = newRootView @@ -93,12 +109,19 @@ public final class ViewMonitor: NSObject { // 遷移はシールドが塞いでいる)場合は、OFF に戻さず新しい画面を // 再スキャンして計測を続ける。選択状態(赤枠・距離の参照)は旧画面の // ビューと結びついているため引き継がない。 - guard measuring, let launcherButton, let rootView else { + guard keepMeasuring, let launcherButton, let rootView else { return } launcherButton.isSelected = true + beginMeasuring(with: launcherButton, on: rootView) + } + + /// オーバーレイを表示して計測状態に入る。トグル ON と reload の復元の + /// 両方から使う共通手順。実行ボタン(停止操作)がタップやドラッグを + /// 奪われないよう、計測 UI を貼った後に最前面へ戻す。 + private func beginMeasuring(with button: MonitorLauncherButton, on rootView: UIView) { overlay.show(on: rootView) - rootView.bringSubviewToFront(launcherButton) + rootView.bringSubviewToFront(button) } private func addLauncherButton() { @@ -118,15 +141,10 @@ public final class ViewMonitor: NSObject { guard let self, let rootView = self.rootView else { return } - self.measuring = isSelected - if isSelected { + if isSelected, let button { + self.beginMeasuring(with: button, on: rootView) + } else if isSelected { self.overlay.show(on: rootView) - // 計測ボタンは rootView に直接addSubviewされるため、 - // show(on:) の後だと実行ボタンより前面に乗ってしまう。 - // 実行ボタン(停止操作)がタップやドラッグを奪われないよう最前面に戻す。 - if let button { - rootView.bringSubviewToFront(button) - } } else { self.overlay.hide() } @@ -157,6 +175,12 @@ public final class ViewMonitor: NSObject { shared.reload(on: view) } + /// テスト用: viewDidAppear 検知相当の遷移を任意の rootView で実行する。 + /// 公開 API には含まれない。 + static func simulateTransitionForTesting(isBeingPresented: Bool, on view: UIView) { + shared.handleTransition(isBeingPresented: isBeingPresented, on: view) + } + private func removeLauncherButton() { launcherButton?.removeFromSuperview() launcherButton = nil @@ -181,15 +205,32 @@ public final class ViewMonitor: NSObject { @objc private func orientationChanged() { - guard started else { + // 1回の物理回転で通知は複数回届く(portrait → faceUp → landscape 等)。 + // 予約を1つに束ね、reload の連打による再スキャンとちらつきを防ぐ。 + guard started, !pendingOrientationReload else { return } + pendingOrientationReload = true + let windowBefore = WindowProvider.keyWindow + let boundsBefore = windowBefore?.bounds // orientationDidChange はウィンドウのリサイズ完了前に届くため、 // ここで即 reload すると旧 bounds のまま配置してしまう // (横→縦で実行ボタンが x=705 など画面外に出る)。次の runloop に // 遅らせ、リサイズ後のジオメトリで貼り直す。 DispatchQueue.main.async { [weak self] in - guard let self, self.started else { + guard let self else { + return + } + self.pendingOrientationReload = false + guard self.started else { + return + } + // faceUp / faceDown / 上下反転など、インターフェイスが回転せず + // ジオメトリが変わらない向きの変化では貼り直さない。reload は + // 選択状態(赤枠・距離の参照・InfoView の表示)を破棄するため、 + // 見た目が何も変わらないのに計測内容だけ消えてしまう。 + let window = WindowProvider.keyWindow + if let window, window === windowBefore, window.bounds == boundsBefore { return } self.reload() diff --git a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift index 30cbe99..c57e8e3 100644 --- a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift +++ b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift @@ -150,6 +150,47 @@ struct ViewMonitorLifecycleTests { ViewMonitor.stop() } + @Test("モーダル提示の遷移では計測を終了する(ダイアログを覆わない)") + func modalPresentationCancelsMeasuring() throws { + // モーダル(エラーダイアログ等)はシールドで塞げない遷移で、計測を + // 維持するとシールドがダイアログの上に乗り、OK ボタンが押せなくなる。 + // モーダル提示だけは従来どおり計測を終了する。 + ViewMonitor.stop() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + window.addSubview(UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20))) + ViewMonitor.simulateLauncherButtonAttachedForTesting(to: window) + let launcher = try #require(window.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + launcher.isSelected = true + launcher.onToggle?(true) + + let dialogWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + dialogWindow.addSubview(UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20))) + ViewMonitor.simulateTransitionForTesting(isBeingPresented: true, on: dialogWindow) + + let newLauncher = try #require(dialogWindow.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + #expect(!newLauncher.isSelected, "モーダル提示でも計測が維持されてしまった") + #expect(!dialogWindow.subviews.contains { $0 is MonitorShieldView }, "シールドがダイアログを覆っている") + ViewMonitor.stop() + } + + @Test("push 相当の遷移(モーダルでない)では計測を維持する") + func nonModalTransitionKeepsMeasuring() throws { + ViewMonitor.stop() + 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) + launcher.isSelected = true + launcher.onToggle?(true) + + let nextWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + ViewMonitor.simulateTransitionForTesting(isBeingPresented: false, on: nextWindow) + + let newLauncher = try #require(nextWindow.subviews.compactMap { $0 as? MonitorLauncherButton }.first) + #expect(newLauncher.isSelected) + newLauncher.onToggle?(false) + ViewMonitor.stop() + } + @Test("計測 OFF なら reload しても OFF のまま") func reloadStaysOffWhenNotMeasuring() throws { ViewMonitor.stop() @@ -166,9 +207,9 @@ struct ViewMonitorLifecycleTests { ViewMonitor.stop() } - @Test("stop すると計測中フラグもリセットされる") + @Test("stop すると計測中状態もリセットされる") func stopClearsMeasuringState() throws { - // stop() 後に start() し直したとき、前回の計測中フラグが残っていると + // stop() 後に start() し直したとき、前回の計測中状態が残っていると // 最初の reload でいきなりオーバーレイが開いてしまう。 // stop() は started ガードを持つため、実運用どおり start() を通す。 ViewMonitor.stop()