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

- 2つのビュー間の距離計測。ビューを選択したあと別のビューを選択すると、直前の選択との関係が InfoView に表示される。離れていればエッジ間ギャップ(`gapX` / `gapY`)、重なっていれば重なり幅(`overlapX` / `overlapY`)、一方が他方を内包していれば内側ビューのインセット(`top` / `left` / `bottom` / `right`)。比較相手には青枠が付き、どのビューとの距離かが画面上で分かる

## [2.1.0] - 2026-08-05

### Added
Expand Down
65 changes: 48 additions & 17 deletions Sources/ViewMonitor/Core/InfoRowBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,64 @@ struct InfoRow: Equatable {
}

/// ViewInspection から表示行を組み立てる。
/// 条件行(フォント系)と数値・色の整形をすべてここに集約し、
/// 条件行(フォント系・距離)と数値・色の整形をすべてここに集約し、
/// UIKit のビュー階層に依存しない純粋関数だけで構成する。
enum InfoRowBuilder {

/// 表示行を組み立てる。
/// nil のときは共通8行をすべて `None` で返す。呼び出し側が計測対象を
/// 取得できなかった場合の防御的な契約で、前の計測値を出し続けない。
static func rows(from inspection: ViewInspection?) -> [InfoRow] {
guard let inspection else {
return ["class", "x", "y", "width", "height", "background", "alpha", "cornerRadius"]
.map { InfoRow(title: $0, value: "None") }
}
/// `inspection` が nil のときは共通8行をすべて `None` で返す。呼び出し側が
/// 計測対象を取得できなかった場合の防御的な契約で、前の計測値を出し続けない。
/// `reference` は直前に選択していたビューの計測結果。`inspection` とともに
/// 非 nil のとき、距離セクション(vs 行+関係に応じた行)を末尾に加える。
static func rows(
from inspection: ViewInspection?,
comparedTo reference: ViewInspection? = nil
) -> [InfoRow] {
var rows = [
InfoRow(title: "class", value: inspection.className),
InfoRow(title: "x", value: format(inspection.frameInWindow.origin.x)),
InfoRow(title: "y", value: format(inspection.frameInWindow.origin.y)),
InfoRow(title: "width", value: format(inspection.size.width)),
InfoRow(title: "height", value: format(inspection.size.height)),
InfoRow(title: "background", value: hex(inspection.backgroundColorHex)),
InfoRow(title: "alpha", value: format(inspection.alpha)),
InfoRow(title: "cornerRadius", value: format(inspection.cornerRadius))
InfoRow(title: "class", value: inspection?.className ?? "None"),
InfoRow(title: "x", value: inspection.map { format($0.frameInWindow.origin.x) } ?? "None"),
InfoRow(title: "y", value: inspection.map { format($0.frameInWindow.origin.y) } ?? "None"),
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")
]
if let font = inspection.font {
if let font = inspection?.font {
rows.append(InfoRow(title: "font", value: font.familyName))
rows.append(InfoRow(title: "fontSize", value: format(font.pointSize)))
rows.append(InfoRow(title: "fontColor", value: hex(font.colorHex)))
}
if let inspection, let reference {
rows.append(contentsOf: distanceRows(from: inspection, to: reference))
}
return rows
}

/// vs 行と、矩形関係に応じた距離行。
/// 分離時に投影が重なっている軸(nil)は行を出さない。
private static func distanceRows(
from inspection: ViewInspection,
to reference: ViewInspection
) -> [InfoRow] {
var rows = [InfoRow(title: "vs", value: reference.className)]
switch RectRelation.between(inspection.frameInWindow, reference.frameInWindow) {
case .separated(let gapX, let gapY):
if let gapX {
rows.append(InfoRow(title: "gapX", value: format(gapX)))
}
if let gapY {
rows.append(InfoRow(title: "gapY", value: format(gapY)))
}
case .overlapping(let overlapX, let overlapY):
rows.append(InfoRow(title: "overlapX", value: format(overlapX)))
rows.append(InfoRow(title: "overlapY", value: format(overlapY)))
case .contained(let insets):
rows.append(InfoRow(title: "top", value: format(insets.top)))
rows.append(InfoRow(title: "left", value: format(insets.left)))
rows.append(InfoRow(title: "bottom", value: format(insets.bottom)))
rows.append(InfoRow(title: "right", value: format(insets.right)))
}
return rows
}

Expand Down
46 changes: 46 additions & 0 deletions Sources/ViewMonitor/Core/RectRelation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// RectRelation.swift
// ViewMonitor
//

import UIKit

/// 2つの矩形の位置関係。ビュー階層に依存しない純粋な判定。
enum RectRelation: Equatable {

/// 少なくとも一方の軸で離れている。投影が重なっている軸は nil。
/// 0 は「ぴったり接している」を意味する有効値。
case separated(gapX: CGFloat?, gapY: CGFloat?)

/// 両軸で投影が重なっているが、内包ではない。値は重なり幅(正)。
case overlapping(overlapX: CGFloat, overlapY: CGFloat)

/// 一方が他方を内包する。インセットは常に「内側の矩形の、外側の
/// 矩形の各辺からの距離」で、どちらを先に渡しても同じ値になる。
case contained(insets: UIEdgeInsets)

/// `a` と `b` の位置関係を判定する。
/// サイズ 0 の矩形も内側にあれば `contains` が真になり内包として扱われる。
/// 外側にあれば gap 計算に落ちる(いずれも許容)。
static func between(_ a: CGRect, _ b: CGRect) -> RectRelation {
if a.contains(b) || b.contains(a) {
let outer = a.contains(b) ? a : b
let inner = a.contains(b) ? b : a
return .contained(insets: UIEdgeInsets(
top: inner.minY - outer.minY,
left: inner.minX - outer.minX,
bottom: outer.maxY - inner.maxY,
right: outer.maxX - inner.maxX
))
}
let gapX = max(a.minX, b.minX) - min(a.maxX, b.maxX)
let gapY = max(a.minY, b.minY) - min(a.maxY, b.maxY)
if gapX < 0 && gapY < 0 {
return .overlapping(overlapX: -gapX, overlapY: -gapY)
}
return .separated(
gapX: gapX >= 0 ? gapX : nil,
gapY: gapY >= 0 ? gapY : nil
)
}
}
30 changes: 27 additions & 3 deletions Sources/ViewMonitor/UI/MonitorOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class MonitorOverlay: NSObject {
/// 計測のために userInteractionEnabled を一時的に有効化したビュー。
/// hide() で元に戻す。
private var forcedInteractionViews: [UIView] = []
/// 直前に選択したボタン。距離計測の参照元。
/// weak なので hide() / 画面遷移の reload でボタンが破棄されれば
/// 自動的に nil に戻り、古い画面のビューとペアになることがない。
private weak var lastSelectedButton: MonitorButton?

init(configuration: MonitorConfiguration = .default) {
self.configuration = configuration
Expand All @@ -45,6 +49,7 @@ final class MonitorOverlay: NSObject {
infoView = nil
forcedInteractionViews.forEach { $0.isUserInteractionEnabled = false }
forcedInteractionViews.removeAll()
lastSelectedButton = nil
rootView = nil
}

Expand Down Expand Up @@ -80,12 +85,16 @@ final class MonitorOverlay: NSObject {
view.addSubview(button)
}

/// 選択状態の切り替え。実行時はボタンの target-action からのみ呼ばれる。
/// MonitorOverlayTests から呼べるよう internal にしている。
@objc
private func select(sender: MonitorButton) {
func select(sender: MonitorButton) {
sender.isSelected.toggle()
guard let infoView else {
return
}
// 距離セクションを表示しているときだけ非 nil。青枠の維持対象。
var referenceButton: MonitorButton?
if sender.isSelected {
infoView.isHidden = false
// 座標変換は show(on:) で受け取った rootView を基準にする。
Expand All @@ -94,11 +103,26 @@ final class MonitorOverlay: NSObject {
// シーンがずれたときに誤ったウィンドウで変換してしまう。
let window = rootView?.window ?? (rootView as? UIWindow)
let inspection = sender.targetView.map { ViewInspector.inspect($0, in: window) }
infoView.update(rows: InfoRowBuilder.rows(from: inspection))
let reference: ViewInspection? = {
guard let last = lastSelectedButton, last !== sender,
let referenceView = last.targetView,
let referenceWindow = referenceView.window, referenceWindow === window else {
return nil
}
return ViewInspector.inspect(referenceView, in: window)
}()
infoView.update(rows: InfoRowBuilder.rows(from: inspection, comparedTo: reference))
sender.layer.borderWidth = 2.0
sender.layer.borderColor = UIColor.red.cgColor
if reference != nil {
referenceButton = lastSelectedButton
referenceButton?.isSelected = false
referenceButton?.layer.borderWidth = 2.0
referenceButton?.layer.borderColor = UIColor.systemBlue.cgColor
}
lastSelectedButton = sender
}
for other in buttons where other !== sender {
for other in buttons where other !== sender && other !== referenceButton {
other.layer.borderWidth = 0.0
other.isSelected = false
}
Expand Down
62 changes: 62 additions & 0 deletions Tests/ViewMonitorTests/InfoRowBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,66 @@ struct InfoRowBuilderTests {

#expect(rows[1] == InfoRow(title: "x", value: pair.1))
}

@Test("分離した参照を渡すと vs 行と gap 行が末尾に並ぶ")
func appendsDistanceRowsForSeparatedReference() {
// 現在: y120 h20 → minY 120 / 参照: y76 h20 → maxY 96 → gapY 24。X は同一投影で nil
let reference = makeInspection(className: "UILabel", y: 76, height: 20)

let rows = InfoRowBuilder.rows(from: makeInspection(), comparedTo: reference)

#expect(Array(rows.suffix(2)) == [
InfoRow(title: "vs", value: "UILabel"),
InfoRow(title: "gapY", value: "24")
])
}

@Test("重なっている参照は overlap 行になる")
func appendsOverlapRows() {
// 現在 x16..359 y120..140 / 参照 x200..543 y130..150 → overlapX 159, overlapY 10
let reference = makeInspection(className: "UIView", x: 200, y: 130)

let rows = InfoRowBuilder.rows(from: makeInspection(), comparedTo: reference)

#expect(Array(rows.suffix(3)) == [
InfoRow(title: "vs", value: "UIView"),
InfoRow(title: "overlapX", value: "159"),
InfoRow(title: "overlapY", value: "10")
])
}

@Test("内包する参照はインセット4行になる")
func appendsInsetRows() {
// 現在 x16..359 y120..140 は参照 x0..375 y100..160 に内包
let reference = makeInspection(className: "UIStackView", x: 0, y: 100, width: 375, height: 60)

let rows = InfoRowBuilder.rows(from: makeInspection(), comparedTo: reference)

#expect(Array(rows.suffix(5)) == [
InfoRow(title: "vs", value: "UIStackView"),
InfoRow(title: "top", value: "20"),
InfoRow(title: "left", value: "16"),
InfoRow(title: "bottom", value: "20"),
InfoRow(title: "right", value: "16")
])
}

@Test("inspection が nil なら参照があっても距離セクションを出さない")
func omitsDistanceSectionForNilInspection() {
let rows = InfoRowBuilder.rows(from: nil, comparedTo: makeInspection())

#expect(rows.map(\.title) == ["class", "x", "y", "width", "height", "background", "alpha", "cornerRadius"])
}

@Test("フォント行の後に距離セクションが来る")
func distanceSectionFollowsFontRows() {
let font = ViewInspection.FontInfo(familyName: "Helvetica", pointSize: 17, colorHex: nil)
let reference = makeInspection(className: "UILabel", y: 76, height: 20)

let rows = InfoRowBuilder.rows(from: makeInspection(font: font), comparedTo: reference)

#expect(rows.count == 13)
#expect(rows[10].title == "fontColor")
#expect(rows[11] == InfoRow(title: "vs", value: "UILabel"))
}
}
Loading
Loading