Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ extension UIViewController {
guard !(self is MonitorInternalViewController) else {
return
}
ViewMonitor.detectedViewDidAppear()
// isBeingPresented は viewDidAppear の時点でまだ true になっている。
// モーダル提示(アラート等)を計測 UI で覆わないための判定に使う。
ViewMonitor.detectedViewDidAppear(isBeingPresented: isBeingPresented)
}
}
4 changes: 4 additions & 0 deletions Sources/ViewMonitor/UI/MonitorLauncherButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 59 additions & 18 deletions Sources/ViewMonitor/ViewMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
}

/// 画面が入れ替わったので、オーバーレイと実行ボタンを貼り直す。
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
45 changes: 43 additions & 2 deletions Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Loading