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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ jobs:
-scheme ViewMonitorSwiftUIExample \
-destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}"

- name: UI test UIKit example
run: |
xcodebuild test \
-project Example/ViewMonitorExample/ViewMonitorExample.xcodeproj \
-scheme ViewMonitorExample \
-destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}"

pod-lint:
name: pod lib lint
runs-on: macos-latest
Expand Down
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ included:
- Sources
- Tests
- Example/ViewMonitorExample/ViewMonitorExample
- Example/ViewMonitorExample/ViewMonitorExampleUITests
- Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample
- Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleUITests

Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ 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

- 実行ボタン・計測ボタン・InfoView に accessibilityIdentifier(`ViewMonitor.launcher` / `ViewMonitor.monitorButton` / `ViewMonitor.infoView`)。UI テストなどの自動化から ViewMonitor の UI を特定できる

### Changed

- 計測中に回転やプログラム起因の画面遷移が起きても、計測 OFF に戻さず新しい画面を再スキャンして計測を続ける(従来は無言で OFF に戻っていた)。選択状態(赤枠・距離計測の参照)は旧画面のビューと結びついているため引き継がない

### Fixed

- 横向きから縦に戻すと実行ボタンが画面外に出ることがある不具合。orientationDidChange はウィンドウのリサイズ完了前に届くため、貼り直しを次の runloop に遅らせてリサイズ後のジオメトリで配置するようにした

## [2.3.0] - 2026-08-09

### Added
Expand Down
354 changes: 234 additions & 120 deletions Example/ViewMonitorExample/ViewMonitorExample.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "59B23151407E1BFFD154790A"
BlueprintIdentifier = "B869FD1851D87A93CF44B03E"
BuildableName = "ViewMonitorExample.app"
BlueprintName = "ViewMonitorExample"
ReferencedContainer = "container:ViewMonitorExample.xcodeproj">
Expand All @@ -30,13 +30,23 @@
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "59B23151407E1BFFD154790A"
BlueprintIdentifier = "B869FD1851D87A93CF44B03E"
BuildableName = "ViewMonitorExample.app"
BlueprintName = "ViewMonitorExample"
ReferencedContainer = "container:ViewMonitorExample.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C852A3795846A1020EAEED26"
BuildableName = "ViewMonitorExampleUITests.xctest"
BlueprintName = "ViewMonitorExampleUITests"
ReferencedContainer = "container:ViewMonitorExample.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand All @@ -53,7 +63,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "59B23151407E1BFFD154790A"
BlueprintIdentifier = "B869FD1851D87A93CF44B03E"
BuildableName = "ViewMonitorExample.app"
BlueprintName = "ViewMonitorExample"
ReferencedContainer = "container:ViewMonitorExample.xcodeproj">
Expand All @@ -70,7 +80,7 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "59B23151407E1BFFD154790A"
BlueprintIdentifier = "B869FD1851D87A93CF44B03E"
BuildableName = "ViewMonitorExample.app"
BlueprintName = "ViewMonitorExample"
ReferencedContainer = "container:ViewMonitorExample.xcodeproj">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import XCTest

/// UIKit ライフサイクルのサンプルに対する実タッチ経路の回帰テスト。
/// 検証の観点は SwiftUI 側(ViewMonitorUITests)と同じで、UIKit だけの
/// 経路(storyboard のビュー階層、SceneDelegate からの起動)でも
/// 計測と回転時の挙動が成り立つことを押さえる。
final class ViewMonitorExampleUITests: XCTestCase {

override func setUpWithError() throws {
continueAfterFailure = false
// 回転テスト後もシミュレータの向きは残るため、各テストを縦から始める。
XCUIDevice.shared.orientation = .portrait
}

/// アプリを起動し、実行ボタンをタップして計測を開始する。
private func launchAndStartMeasuring(_ app: XCUIApplication) {
app.launch()
let launcher = app.buttons["ViewMonitor.launcher"]
XCTAssertTrue(launcher.waitForExistence(timeout: 10), "実行ボタンが表示されない")
launcher.tap()
let monitorButton = app.buttons.matching(identifier: "ViewMonitor.monitorButton").firstMatch
XCTAssertTrue(monitorButton.waitForExistence(timeout: 5), "トグルONで計測ボタンが出ない")
}

func testMeasuresLabelViaRealTap() {
let app = XCUIApplication()
launchAndStartMeasuring(app)

let label = app.staticTexts["ViewMonitor Library"]
XCTAssertTrue(label.exists, "ラベルが見つからない")
label.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()

XCTAssertTrue(
app.staticTexts["class: UILabel"].waitForExistence(timeout: 5),
"UILabel の計測結果が InfoView に出ない"
)
}

func testRotationKeepsMeasuringAndLauncherStaysOnScreen() {
let app = XCUIApplication()
launchAndStartMeasuring(app)

// 横 → 縦の順で確認する。orientationDidChange はウィンドウのリサイズ
// 完了前に届くため、旧 bounds で配置すると縦復帰時に実行ボタンが
// 画面外へ出る(SwiftUI 側と共通の退行検出)。
for orientation in [UIDeviceOrientation.landscapeLeft, .portrait] {
XCUIDevice.shared.orientation = orientation

let launcher = app.buttons["ViewMonitor.launcher"]
XCTAssertTrue(launcher.waitForExistence(timeout: 5))
XCTAssertTrue(launcher.isSelected, "回転で計測が OFF に戻ってしまった (\(orientation.rawValue))")

let window = app.windows.firstMatch.frame
XCTAssertTrue(
window.contains(launcher.frame),
"実行ボタンが画面外に出た (\(orientation.rawValue)): launcher=\(launcher.frame) window=\(window)"
)
XCTAssertTrue(
app.buttons.matching(identifier: "ViewMonitor.monitorButton").firstMatch.exists,
"回転後に再スキャンされていない (\(orientation.rawValue))"
)
}
}
}
27 changes: 27 additions & 0 deletions Example/ViewMonitorExample/generate_project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
PROJECT_PATH = File.join(EXAMPLE_DIR, 'ViewMonitorExample.xcodeproj')
TARGET_NAME = 'ViewMonitorExample'
BUNDLE_ID = 'daisuke.ViewMonitorExample'
UITEST_TARGET_NAME = 'ViewMonitorExampleUITests'
UITEST_DIR = File.join(EXAMPLE_DIR, UITEST_TARGET_NAME)
DEPLOYMENT_TARGET = '15.0'
XCCONFIG_NAME = 'Signing.xcconfig'

Expand All @@ -29,6 +31,8 @@
end

target = project.new_target(:application, TARGET_NAME, :ios, DEPLOYMENT_TARGET)
uitest_target = project.new_target(:ui_test_bundle, UITEST_TARGET_NAME, :ios, DEPLOYMENT_TARGET)
uitest_target.add_dependency(target)

# `new_target` links Foundation.framework by default, but its file reference
# hardcodes the version of the installed SDK into the path (e.g.
Expand All @@ -38,6 +42,7 @@
# 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
uitest_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
Expand All @@ -61,6 +66,13 @@
target.add_resources([file])
end

uitest_group = project.new_group(UITEST_TARGET_NAME, UITEST_TARGET_NAME)
uitest_sources = %w[ViewMonitorExampleUITests.swift]
uitest_sources.each do |name|
file = uitest_group.new_reference(File.join(UITEST_DIR, name))
uitest_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 = '../..'
Expand Down Expand Up @@ -98,12 +110,27 @@
settings.delete('ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME')
end

uitest_target.build_configurations.each do |config|
settings = config.build_settings
settings['PRODUCT_BUNDLE_IDENTIFIER'] = "#{BUNDLE_ID}UITests"
settings['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET
settings['SWIFT_VERSION'] = '6.0'
settings['GENERATE_INFOPLIST_FILE'] = 'YES'
settings['TEST_TARGET_NAME'] = TARGET_NAME
# The UI test runner only ever runs on the simulator in this repo
# (CI has no signing certificate), same reasoning as the app target.
settings['CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]'] = 'NO'
settings['CODE_SIGN_STYLE'] = 'Automatic'
end

project.save

# Create a shared scheme so CI can refer to it with -scheme ViewMonitorExample.
# The test action carries the UI test bundle so `xcodebuild test` runs it.
scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(target)
scheme.set_launch_target(target)
scheme.add_test_target(uitest_target)
scheme.save_as(PROJECT_PATH, TARGET_NAME, true)

puts "Generated #{PROJECT_PATH}"
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ final class ViewMonitorUITests: XCTestCase {

override func setUpWithError() throws {
continueAfterFailure = false
// 回転テスト後もシミュレータの向きは残るため、各テストを縦から始める。
XCUIDevice.shared.orientation = .portrait
}

/// アプリを起動し、実行ボタンをタップして計測を開始する。
Expand Down Expand Up @@ -79,6 +81,32 @@ final class ViewMonitorUITests: XCTestCase {
XCTAssertGreaterThanOrEqual(buttons.count, 5, "List の行に計測ボタンが出ていない")
}

func testRotationKeepsMeasuringAndLauncherStaysOnScreen() {
let app = XCUIApplication()
launchAndStartMeasuring(app)

// 横 → 縦の順で確認する。orientationDidChange はウィンドウのリサイズ
// 完了前に届くため、旧 bounds(横向きの幅)で実行ボタンを配置すると
// 縦に戻したとき画面外へ出る、という報告があった。
for orientation in [UIDeviceOrientation.landscapeLeft, .portrait] {
XCUIDevice.shared.orientation = orientation

let launcher = app.buttons["ViewMonitor.launcher"]
XCTAssertTrue(launcher.waitForExistence(timeout: 5))
XCTAssertTrue(launcher.isSelected, "回転で計測が OFF に戻ってしまった (\(orientation.rawValue))")

let window = app.windows.firstMatch.frame
XCTAssertTrue(
window.contains(launcher.frame),
"実行ボタンが画面外に出た (\(orientation.rawValue)): launcher=\(launcher.frame) window=\(window)"
)
XCTAssertTrue(
app.buttons.matching(identifier: "ViewMonitor.monitorButton").firstMatch.exists,
"回転後に再スキャンされていない (\(orientation.rawValue))"
)
}
}

func testShieldBlocksAppInteractionWhileMeasuring() {
let app = XCUIApplication()
app.launch()
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ 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. 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
captured when the toggle was turned ON. If the screen still changes while
measuring (device rotation, or a programmatic transition such as a timer-
driven push), measuring stays ON and the new screen is re-scanned; the
current selection resets. 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.

Expand Down
41 changes: 39 additions & 2 deletions Sources/ViewMonitor/ViewMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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

/// 計測を開始する。実行ボタンが画面に表示される。
public static func start() {
Expand Down Expand Up @@ -62,6 +65,7 @@ public final class ViewMonitor: NSObject {
shared.removeLauncherButton()
shared.stopObservingOrientation()
shared.started = false
shared.measuring = false
}

/// `viewDidAppear` の swizzling から呼ばれる。
Expand All @@ -74,10 +78,27 @@ public final class ViewMonitor: NSObject {

/// 画面が入れ替わったので、オーバーレイと実行ボタンを貼り直す。
private func reload() {
reload(on: WindowProvider.keyWindow)
}

/// テスト用に rootView を注入できる本体。ユニットテストのバンドルには
/// 接続済みの window scene が無く `WindowProvider.keyWindow` が常に nil に
/// なるため、貼り直し先を差し替えられるようにしている。
private func reload(on newRootView: UIView?) {
overlay.hide()
removeLauncherButton()
rootView = WindowProvider.keyWindow
rootView = newRootView
addLauncherButton()
// 計測中に reload が起きた(回転・プログラム起因の画面遷移。タップ起因の
// 遷移はシールドが塞いでいる)場合は、OFF に戻さず新しい画面を
// 再スキャンして計測を続ける。選択状態(赤枠・距離の参照)は旧画面の
// ビューと結びついているため引き継がない。
guard measuring, let launcherButton, let rootView else {
return
}
launcherButton.isSelected = true
overlay.show(on: rootView)
rootView.bringSubviewToFront(launcherButton)
}

private func addLauncherButton() {
Expand All @@ -97,6 +118,7 @@ public final class ViewMonitor: NSObject {
guard let self, let rootView = self.rootView else {
return
}
self.measuring = isSelected
if isSelected {
self.overlay.show(on: rootView)
// 計測ボタンは rootView に直接addSubviewされるため、
Expand Down Expand Up @@ -129,6 +151,12 @@ public final class ViewMonitor: NSObject {
shared.addLauncherButton()
}

/// テスト用: 画面遷移・回転相当の `reload()` を任意の rootView で実行する。
/// 公開 API には含まれない。
static func simulateReloadForTesting(on view: UIView) {
shared.reload(on: view)
}

private func removeLauncherButton() {
launcherButton?.removeFromSuperview()
launcherButton = nil
Expand Down Expand Up @@ -156,6 +184,15 @@ public final class ViewMonitor: NSObject {
guard started else {
return
}
reload()
// orientationDidChange はウィンドウのリサイズ完了前に届くため、
// ここで即 reload すると旧 bounds のまま配置してしまう
// (横→縦で実行ボタンが x=705 など画面外に出る)。次の runloop に
// 遅らせ、リサイズ後のジオメトリで貼り直す。
DispatchQueue.main.async { [weak self] in
guard let self, self.started else {
return
}
self.reload()
}
}
}
Loading
Loading