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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9044022..7ab4713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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 対応。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件のままにしない) +- `List` の行の検出。List はホスティングビューの下に UICollectionView を挟み、行のアクセシビリティ要素は各セル内のビューが公開するため、ホスティングビュー配下のすべてのビューを走査対象にした(行は List の仕様どおり1行=1要素として計測される) +- 計測中はアプリ本体へのタッチ(タップ・スクロール・エッジスワイプ)を遮断。誤操作でアクションが発火したり、画面遷移で計測状態が破棄されたりしない。計測ボタン・InfoView・実行ボタンなど ViewMonitor 自身の UI だけが操作できる + +### Changed + +- `UIHostingController` を埋め込んだ画面でも SwiftUI 要素に計測ボタンが表示されるように +- `InfoView` の内部描画を SwiftUI 化(挙動変更なし) +- 計測ボタンは UIKit 対象・SwiftUI 要素とも rootView 直下の固定配置に統一(対象ビューの subview には追加しない)。SwiftUI のホスティングビューのヒットテストが非決定的で、対象ビュー内に置いたボタンへのタッチが実機で届かないことがあるため。あわせて、隠れている対象(大タイトル表示中のインラインナビタイトルなど)にはボタンを付けない + ## [2.2.0] - 2026-08-06 ### Added 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..a56b3b1 --- /dev/null +++ b/Example/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExample/ViewMonitorSwiftUIExampleApp.swift @@ -0,0 +1,21 @@ +import SwiftUI +import ViewMonitor + +@main +struct ViewMonitorSwiftUIExampleApp: App { + + init() { + // SwiftUI 要素の検出はアクセシビリティツリーに依存し、iOS は + // アクセシビリティクライアント接続中しかツリーを構築しない。 + // DEBUG ビルド限定の API でプロセス内から構築を有効化する + // (リリースビルドでは何もしない)。 + ViewMonitor.enableSwiftUIElementDetection() + } + + 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}" diff --git a/README.md b/README.md index 267b3ef..2733579 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,83 @@ 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`. -### Running the sample on a device +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. 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. + +### SwiftUI + +For apps using the SwiftUI lifecycle (`@main App`), attach `.viewMonitor()` +to the root view: -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: +```swift +import SwiftUI +import ViewMonitor + +@main +struct MyApp: App { + init() { + ViewMonitor.enableSwiftUIElementDetection() + } + + 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`. + +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: + +- Measured values are limited to position, size, and text content + (font / background / cornerRadius show `None`). +- Views combined with `.accessibilityElement(children: .combine)` are + measured as a single element, and `.accessibilityHidden(true)` views are + not detected. + +### Running the samples on a device + +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 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/InfoRowBuilder.swift b/Sources/ViewMonitor/Core/InfoRowBuilder.swift index bd96ce6..bede5af 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))) @@ -46,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/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/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift index 33e1bb2..bfbd4ad 100644 --- a/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift +++ b/Sources/ViewMonitor/Core/ViewHierarchyScanner.swift @@ -1,31 +1,103 @@ 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] { + 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 [] } - var result: [UIView] = [] + var result: [MeasurementTarget] = [] if isTarget(root) { - result.append(root) + result.append(.uiKitView(root)) + } + 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: targets(in: subview)) + result.append( + contentsOf: collectTargets( + in: subview, underHostingView: inHostingSubtree, seenElements: &seenElements + ) + ) } 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] { + 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/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..9b8f671 100644 --- a/Sources/ViewMonitor/Core/ViewInspector.swift +++ b/Sources/ViewMonitor/Core/ViewInspector.swift @@ -19,10 +19,32 @@ enum ViewInspector { backgroundColorHex: view.backgroundColor?.monitorHexString, alpha: view.alpha, cornerRadius: view.layer.cornerRadius, + text: nil, font: fontInfo(of: view) ) } + /// 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/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/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/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/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..4fab089 100644 --- a/Sources/ViewMonitor/UI/InfoView.swift +++ b/Sources/ViewMonitor/UI/InfoView.swift @@ -3,36 +3,32 @@ // 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) + /// ViewMonitor 内部の VC だと判別できる専用型を使う。素の + /// `UIHostingController` だと、この VC が発火させる `viewDidAppear` を + /// swizzling がアプリの画面遷移と誤認し、表示直後のオーバーレイを + /// `reload()` が畳んでしまう。 + private let hostingController = MonitorHostingController(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 +43,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/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/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/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index cdd187d..306644a 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -16,29 +16,64 @@ final class MonitorOverlay: NSObject { private weak var rootView: UIView? private var infoView: InfoView? + private var shieldView: MonitorShieldView? private var buttons: [MonitorButton] = [] - /// 計測のために userInteractionEnabled を一時的に有効化したビュー。 - /// hide() で元に戻す。 - private var forcedInteractionViews: [UIView] = [] /// 直前に選択したボタン。距離計測の参照元。 /// weak なので hide() / 画面遷移の reload でボタンが破棄されれば /// 自動的に 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() } /// オーバーレイを構築して表示する。 + /// 盾 → InfoView → 計測ボタンの順に追加し、盾が計測 UI の背面・ + /// アプリ本体の前面に入るようにする。 func show(on rootView: UIView) { hide() self.rootView = rootView + addShieldView(to: rootView) addInfoView(to: rootView) - for view in scanner.targets(in: rootView) { - addMonitorButton(on: view) + let targets = scanner.measurementTargets(in: rootView) + for target in targets { + addMonitorButton(for: target, rootView: rootView) + } + showAccessibilityNoticeIfNeeded(for: targets, rootView: rootView) + // 計測ボタンは rootView に直接addSubviewするため、infoView より後に + // 追加されると重なり順で上に乗ってしまう。 + // ドラッグ用ジェスチャの奪い合いを防ぐため、追加後に最前面へ戻す。 + if let infoView { + rootView.bringSubviewToFront(infoView) + } + } + + /// ホスティングビューがあるのにアクセシビリティ要素を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 } /// オーバーレイを取り除き、変更したビューの状態を元に戻す。 @@ -47,12 +82,21 @@ final class MonitorOverlay: NSObject { buttons.removeAll() infoView?.removeFromSuperview() infoView = nil - forcedInteractionViews.forEach { $0.isUserInteractionEnabled = false } - forcedInteractionViews.removeAll() + shieldView?.removeFromSuperview() + shieldView = nil 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( @@ -68,21 +112,57 @@ final class MonitorOverlay: NSObject { self.infoView = infoView } - private func addMonitorButton(on view: UIView) { - let button = MonitorButton(frame: CGRect(origin: .zero, size: view.frame.size)) + /// 計測ボタンは 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): + // ボタンは固定配置のため、隠れた対象(大タイトル表示中の + // インラインナビタイトルなど)にボタンを付けると、何もない場所に + // ボタンだけが浮いてしまう。不可視の対象には付けない。 + guard Self.isEffectivelyVisible(view) else { + return + } + 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 + } + 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 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 +177,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 +211,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/Sources/ViewMonitor/UI/MonitorShieldView.swift b/Sources/ViewMonitor/UI/MonitorShieldView.swift new file mode 100644 index 0000000..e2fc742 --- /dev/null +++ b/Sources/ViewMonitor/UI/MonitorShieldView.swift @@ -0,0 +1,21 @@ +// +// MonitorShieldView.swift +// ViewMonitor +// + +import UIKit + +/// 計測中にアプリ本体へのタッチを遮る透明ビュー。 +/// +/// 計測ボタンに覆われていない領域へのタップ・スクロール・エッジスワイプが +/// アプリ側に届くと、ボタンの誤発火だけでなく、画面遷移で計測状態ごと +/// 破棄されてしまう(遷移検知の reload が実行ボタンを OFF の新品に差し替える)。 +/// +/// 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 bdd9c4d..b580d48 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 { @@ -68,12 +90,21 @@ 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 } if isSelected { self.overlay.show(on: rootView) + // 計測ボタンは rootView に直接addSubviewされるため、 + // show(on:) の後だと実行ボタンより前面に乗ってしまう。 + // 実行ボタン(停止操作)がタップやドラッグを奪われないよう最前面に戻す。 + if let button { + rootView.bringSubviewToFront(button) + } } else { self.overlay.hide() } @@ -85,6 +116,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/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) + } +} 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")) + } } 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/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/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) + } +} diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index 4879f12..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? { @@ -21,7 +34,27 @@ 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つ作る。 + 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つ目の選択では距離セクションが出ない") @@ -92,8 +125,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() @@ -101,7 +134,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 +207,100 @@ 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")) + } + + @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) + } } diff --git a/Tests/ViewMonitorTests/MonitorShieldTests.swift b/Tests/ViewMonitorTests/MonitorShieldTests.swift new file mode 100644 index 0000000..b7d9241 --- /dev/null +++ b/Tests/ViewMonitorTests/MonitorShieldTests.swift @@ -0,0 +1,129 @@ +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 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 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() + 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() + } +} diff --git a/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift b/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift new file mode 100644 index 0000000..8ff390c --- /dev/null +++ b/Tests/ViewMonitorTests/SwiftUIElementDetectionTests.swift @@ -0,0 +1,91 @@ +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 + } + + @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 d6b4cef..48f6bac 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,120 @@ 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("ホスティングビュー配下の非ホスティングビューが持つ 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() + 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())) + } } 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)) + } } diff --git a/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift b/Tests/ViewMonitorTests/ViewMonitorLifecycleTests.swift index 17c4e69..466c030 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,125 @@ 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 状態に依存せず実行ボタンを取り付けられるため、この検証には不要。 + // (かつては addInfoView() 内の UIHostingController が誘発する viewDidAppear で + // reload() が走ってしまうため start() を避けていたが、内部 VC を + // MonitorInternalViewController として検知対象から外したので解消済み。) + 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) + } + + @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 を強参照キャプチャすると + // 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() + } } 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() + } +}