Skip to content

Instantly share code, notes, and snippets.

@natmark
Last active October 24, 2023 01:35
Show Gist options
  • Save natmark/ef27845aff19059e74916df421223b79 to your computer and use it in GitHub Desktop.
Save natmark/ef27845aff19059e74916df421223b79 to your computer and use it in GitHub Desktop.
https://techlife.cookpad.com/entry/2023/09/14/160000 で紹介しているUI要素のフレーム描画を行うデバッグ用のView実装です
// This project is licensed under the MIT No Attribution license.
//
// Copyright (c) 2023 Cookpad Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
final class DebugFrameInspectorView: UIView {
private lazy var numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSize = 3
formatter.groupingSeparator = ","
formatter.maximumFractionDigits = 2
formatter.roundingMode = .halfUp
return formatter
}()
private struct AccentColor {
static var red: CGColor = UIColor.red.cgColor
static var blue: CGColor = UIColor(red: 88 / 255.0, green: 185 / 255.0, blue: 248 / 255.0, alpha: 1.0).cgColor
static var purple: CGColor = UIColor(red: 152 / 255.0, green: 93 / 255.0, blue: 246 / 255.0, alpha: 1.0).cgColor
static var green: CGColor = UIColor(red: 95 / 255.0, green: 204 / 255.0, blue: 137 / 255.0, alpha: 1.0).cgColor
}
private struct ViewWireframe {
var rect: CGRect
var cornerRadius: CGFloat
var maskedCorners: CACornerMask
}
private struct SinglePressValue {
var viewWireframe: ViewWireframe
}
private struct DoublePressValue {
var baseViewWireframe: ViewWireframe
var targetViewWireframe: ViewWireframe
}
private var singlePressValue: SinglePressValue? = nil
private var doublePressValue: DoublePressValue? = nil
init() {
super.init(frame: .zero)
backgroundColor = .clear
isUserInteractionEnabled = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
if let singlePressValue {
drawViewWireframe(
singlePressValue.viewWireframe,
shouldShowScreenMargin: true,
shouldShowCorner: true
)
}
if let doublePressValue {
drawViewWireframe(
doublePressValue.baseViewWireframe,
shouldShowScreenMargin: false,
shouldShowCorner: false
)
drawViewWireframe(
doublePressValue.targetViewWireframe,
shouldShowScreenMargin: false,
shouldShowCorner: false
)
drawComponentMarginLine(
baseRect: doublePressValue.baseViewWireframe.rect,
targetRect: doublePressValue.targetViewWireframe.rect
)
}
}
func setup() {
let singleLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didSingleLongPress(_:)))
singleLongPressGestureRecognizer.minimumPressDuration = 0.2
singleLongPressGestureRecognizer.numberOfTouchesRequired = 1
window?.addGestureRecognizer(singleLongPressGestureRecognizer)
let doubleLongPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didDoubleLongPress(_:)))
doubleLongPressGestureRecognizer.minimumPressDuration = 0.2
doubleLongPressGestureRecognizer.numberOfTouchesRequired = 2
window?.addGestureRecognizer(doubleLongPressGestureRecognizer)
}
/// 1点で長押しした際に呼ばれる関数
@objc private func didSingleLongPress(_ sender: UILongPressGestureRecognizer) {
if sender.state == .began {
let positionInWindow = sender.location(in: window)
if let hitView = window?.hitTest(positionInWindow, with: nil) {
let positionInHitView = sender.location(in: hitView)
let globalRect = CGRect(
x: positionInWindow.x - positionInHitView.x,
y: positionInWindow.y - positionInHitView.y,
width: hitView.frame.size.width,
height: hitView.frame.size.height
)
singlePressValue = SinglePressValue(
viewWireframe: .init(
rect: globalRect,
cornerRadius: hitView.layer.cornerRadius,
maskedCorners: hitView.layer.maskedCorners
)
)
setNeedsDisplay()
}
} else if sender.state == .ended {
singlePressValue = nil
setNeedsDisplay()
}
}
/// 2点で長押しした際に呼ばれる関数
@objc private func didDoubleLongPress(_ sender: UILongPressGestureRecognizer) {
if sender.state == .began {
if sender.numberOfTouches != 2 { return }
let position1InWindow = sender.location(ofTouch: 0, in: window)
let position2InWindow = sender.location(ofTouch: 1, in: window)
if let hitView1 = window?.hitTest(position1InWindow, with: nil), let hitView2 = window?.hitTest(position2InWindow, with: nil) {
let positionInHitView1 = sender.location(ofTouch: 0, in: hitView1)
let positionInHitView2 = sender.location(ofTouch: 1, in: hitView2)
let globalRect1 = CGRect(
x: position1InWindow.x - positionInHitView1.x,
y: position1InWindow.y - positionInHitView1.y,
width: hitView1.frame.size.width,
height: hitView1.frame.size.height
)
let globalRect2 = CGRect(
x: position2InWindow.x - positionInHitView2.x,
y: position2InWindow.y - positionInHitView2.y,
width: hitView2.frame.size.width,
height: hitView2.frame.size.height
)
doublePressValue = DoublePressValue(
baseViewWireframe: .init(
rect: globalRect1,
cornerRadius: hitView1.layer.cornerRadius,
maskedCorners: hitView1.layer.maskedCorners
),
targetViewWireframe: .init(
rect: globalRect2,
cornerRadius: hitView2.layer.cornerRadius,
maskedCorners: hitView2.layer.maskedCorners
)
)
setNeedsDisplay()
}
} else if sender.state == .ended {
doublePressValue = nil
setNeedsDisplay()
}
}
/// View間のマージン描画用関数
private func drawComponentMarginLine(baseRect: CGRect, targetRect: CGRect) {
let isTargetFrameOnTheRight = (targetRect.minX - baseRect.maxX) >= 0
let isTargetFrameOnTheLeft = (baseRect.minX - targetRect.maxX) >= 0
let isTargetFrameOnTheBottom = (targetRect.minY - baseRect.maxY) >= 0
let isTargetFrameOnTheTop = (baseRect.minY - targetRect.maxY) >= 0
let isMidXCoveredTargetFrame = targetRect.minX <= baseRect.midX && targetRect.maxX >= baseRect.midX
let isMidYCoveredTargetFrame = targetRect.minY <= baseRect.midY && targetRect.maxY >= baseRect.midY
let deltaX = targetRect.midX - baseRect.midX
let deltaY = targetRect.midY - baseRect.midY
// 実線のみ
let top = isMidXCoveredTargetFrame && isTargetFrameOnTheTop
let bottom = isMidXCoveredTargetFrame && isTargetFrameOnTheBottom
let leading = isMidYCoveredTargetFrame && isTargetFrameOnTheLeft
let trailing = isMidYCoveredTargetFrame && isTargetFrameOnTheRight
// 前半部分が実線の方向、後半部分が破線の方向
let topLeading = !isMidXCoveredTargetFrame && isTargetFrameOnTheTop && deltaX < 0
let topTrailing = !isMidXCoveredTargetFrame && isTargetFrameOnTheTop && deltaX >= 0
let bottomLeading = !isMidXCoveredTargetFrame && isTargetFrameOnTheBottom && deltaX < 0
let bottomTrailing = !isMidXCoveredTargetFrame && isTargetFrameOnTheBottom && deltaX >= 0
let leadingTop = !isMidYCoveredTargetFrame && isTargetFrameOnTheLeft && deltaY < 0
let leadingBottom = !isMidYCoveredTargetFrame && isTargetFrameOnTheLeft && deltaY >= 0
let trailingTop = !isMidYCoveredTargetFrame && isTargetFrameOnTheRight && deltaY < 0
let trailingBottom = !isMidYCoveredTargetFrame && isTargetFrameOnTheRight && deltaY >= 0
// 実線 (上)
if top || topLeading || topTrailing {
drawVerticalLine(
startY: baseRect.minY,
endY: targetRect.maxY,
positionX: baseRect.midX,
color: AccentColor.red,
isDash: false,
showsTooltip: true
)
}
// 実線 (下)
if bottom || bottomLeading || bottomTrailing {
drawVerticalLine(
startY: baseRect.maxY,
endY: targetRect.minY,
positionX: baseRect.midX,
color: AccentColor.red,
isDash: false,
showsTooltip: true
)
}
// 実線 (左)
if leading || leadingTop || leadingBottom {
drawHorizontalLine(
startX: baseRect.minX,
endX: targetRect.maxX,
positionY: baseRect.midY,
color: AccentColor.red,
isDash: false,
showsTooltip: true
)
}
// 実線 (右)
if trailing || trailingTop || trailingBottom {
drawHorizontalLine(
startX: baseRect.maxX,
endX: targetRect.minX,
positionY: baseRect.midY,
color: AccentColor.red,
isDash: false,
showsTooltip: true
)
}
// 破線 (上方向の実線から左へ)
if topLeading {
drawHorizontalLine(
startX: baseRect.midX,
endX: targetRect.maxX,
positionY: targetRect.maxY,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (上方向の実線から右へ)
if topTrailing {
drawHorizontalLine(
startX: baseRect.midX,
endX: targetRect.minX,
positionY: targetRect.maxY,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (下方向の実線から左へ)
if bottomLeading {
drawHorizontalLine(
startX: baseRect.midX,
endX: targetRect.maxX,
positionY: targetRect.minY,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (下方向の実線から右へ)
if bottomTrailing {
drawHorizontalLine(
startX: baseRect.midX,
endX: targetRect.minX,
positionY: targetRect.minY,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (左方向の実線から上へ)
if leadingTop {
drawVerticalLine(
startY: baseRect.midY,
endY: targetRect.maxY,
positionX: targetRect.maxX,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (左方向の実線から下へ)
if leadingBottom {
drawVerticalLine(
startY: baseRect.midY,
endY: targetRect.minY,
positionX: targetRect.maxX,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (右方向の実線から上へ)
if trailingTop {
drawVerticalLine(
startY: baseRect.midY,
endY: targetRect.maxY,
positionX: targetRect.minX,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
// 破線 (右方向の実線から下へ)
if trailingBottom {
drawVerticalLine(
startY: baseRect.midY,
endY: targetRect.minY,
positionX: targetRect.minX,
color: AccentColor.red,
isDash: true,
showsTooltip: false
)
}
}
/// Viewのフレーム描画用関数
private func drawViewWireframe(_ viewWireframe: ViewWireframe, shouldShowScreenMargin: Bool, shouldShowCorner: Bool) {
// Viewのフレーム表示
drawing(mode: .stroke) { context in
context.setStrokeColor(AccentColor.purple)
context.setLineWidth(0.5)
context.addRect(viewWireframe.rect)
context.strokePath()
}
// Viewのフレームサイズ表示
let formattedWidth = numberFormatter.string(from: NSNumber(value: viewWireframe.rect.width)) ?? ""
let formattedHeight = numberFormatter.string(from: NSNumber(value: viewWireframe.rect.height)) ?? ""
drawTextTooltip(
text: "\(formattedWidth)×\(formattedHeight)",
maxSize: .init(width: 64, height: 20),
anchorPoint: .init(x: viewWireframe.rect.midX, y: viewWireframe.rect.maxY + 5),
color: AccentColor.purple
)
// スクリーンとのマージン表示
if let window, shouldShowScreenMargin {
drawOuterMargin(
baseRect: viewWireframe.rect,
targetRect: window.frame
)
}
// 角丸表示
if shouldShowCorner {
drawCornerRadius(viewWireframe: viewWireframe)
}
}
/// 角丸の描画用関数
private func drawCornerRadius(viewWireframe: ViewWireframe) {
if viewWireframe.cornerRadius == .zero { return }
let ellipseSize = CGSize(width: 6, height: 6)
// 左上
if viewWireframe.maskedCorners.contains(.layerMinXMinYCorner) {
drawing(mode: .fillStroke) { context in
context.setStrokeColor(AccentColor.green)
context.setFillColor(UIColor.white.cgColor)
context.setLineWidth(1.0)
context.addEllipse(in: .init(
x: viewWireframe.rect.minX + max(ellipseSize.width, viewWireframe.cornerRadius / 2) - ellipseSize.width / 2,
y: viewWireframe.rect.minY + max(ellipseSize.height, viewWireframe.cornerRadius / 2) - ellipseSize.height / 2,
width: ellipseSize.width,
height: ellipseSize.height
))
context.drawPath(using: .fillStroke)
}
drawing(mode: .stroke) { context in
context.setStrokeColor(AccentColor.green)
context.setLineWidth(2.0)
context.addArc(
center: .init(x: viewWireframe.rect.minX + viewWireframe.cornerRadius, y: viewWireframe.rect.minY + viewWireframe.cornerRadius),
radius: viewWireframe.cornerRadius,
startAngle: radians(180),
endAngle: radians(270),
clockwise: false
)
context.strokePath()
}
drawTextTooltip(
text: "\(viewWireframe.cornerRadius)",
maxSize: .init(width: 24, height: 20),
anchorPoint: .init(x: viewWireframe.rect.minX - 10, y: viewWireframe.rect.minY - 20),
color: AccentColor.green
)
}
// 右上
if viewWireframe.maskedCorners.contains(.layerMinXMaxYCorner) {
drawing(mode: .fillStroke) { context in
context.setStrokeColor(AccentColor.green)
context.setFillColor(UIColor.white.cgColor)
context.setLineWidth(1.0)
context.addEllipse(in: .init(
x: viewWireframe.rect.maxX - max(ellipseSize.width, viewWireframe.cornerRadius / 2) - ellipseSize.width / 2,
y: viewWireframe.rect.minY + max(ellipseSize.height, viewWireframe.cornerRadius / 2) - ellipseSize.height / 2,
width: ellipseSize.width,
height: ellipseSize.height
))
context.drawPath(using: .fillStroke)
}
drawing(mode: .stroke) { context in
context.setStrokeColor(AccentColor.green)
context.setLineWidth(2.0)
context.addArc(
center: .init(x: viewWireframe.rect.maxX - viewWireframe.cornerRadius, y: viewWireframe.rect.minY + viewWireframe.cornerRadius),
radius: viewWireframe.cornerRadius,
startAngle: radians(270),
endAngle: radians(360),
clockwise: false
)
context.strokePath()
}
drawTextTooltip(
text: "\(viewWireframe.cornerRadius)",
maxSize: .init(width: 24, height: 20),
anchorPoint: .init(x: viewWireframe.rect.maxX + 10, y: viewWireframe.rect.minY - 20),
color: AccentColor.green
)
}
// 左下
if viewWireframe.maskedCorners.contains(.layerMaxXMinYCorner) {
drawing(mode: .fillStroke) { context in
context.setStrokeColor(AccentColor.green)
context.setFillColor(UIColor.white.cgColor)
context.setLineWidth(1.0)
context.addEllipse(in: .init(
x: viewWireframe.rect.minX + max(ellipseSize.width, viewWireframe.cornerRadius / 2) - ellipseSize.width / 2,
y: viewWireframe.rect.maxY - max(ellipseSize.height, viewWireframe.cornerRadius / 2) - ellipseSize.height / 2,
width: ellipseSize.width,
height: ellipseSize.height
))
context.drawPath(using: .fillStroke)
}
drawing(mode: .stroke) { context in
context.setStrokeColor(AccentColor.green)
context.setLineWidth(2.0)
context.addArc(
center: .init(x: viewWireframe.rect.minX + viewWireframe.cornerRadius, y: viewWireframe.rect.maxY - viewWireframe.cornerRadius),
radius: viewWireframe.cornerRadius,
startAngle: radians(90),
endAngle: radians(180),
clockwise: false
)
context.strokePath()
}
drawTextTooltip(
text: "\(viewWireframe.cornerRadius)",
maxSize: .init(width: 24, height: 20),
anchorPoint: .init(x: viewWireframe.rect.minX - 10, y: viewWireframe.rect.maxY + 2),
color: AccentColor.green
)
}
// 右下
if viewWireframe.maskedCorners.contains(.layerMaxXMaxYCorner) {
drawing(mode: .fillStroke) { context in
context.setStrokeColor(AccentColor.green)
context.setFillColor(UIColor.white.cgColor)
context.setLineWidth(1.0)
context.addEllipse(in: .init(
x: viewWireframe.rect.maxX - max(ellipseSize.height, viewWireframe.cornerRadius / 2) - ellipseSize.width / 2,
y: viewWireframe.rect.maxY - max(ellipseSize.height, viewWireframe.cornerRadius / 2) - ellipseSize.height / 2,
width: ellipseSize.width,
height: ellipseSize.height
))
context.drawPath(using: .fillStroke)
}
drawing(mode: .stroke) { context in
context.setStrokeColor(AccentColor.green)
context.setLineWidth(2.0)
context.addArc(
center: .init(x: viewWireframe.rect.maxX - viewWireframe.cornerRadius, y: viewWireframe.rect.maxY - viewWireframe.cornerRadius),
radius: viewWireframe.cornerRadius,
startAngle: radians(0),
endAngle: radians(90),
clockwise: false
)
context.strokePath()
}
drawTextTooltip(
text: "\(viewWireframe.cornerRadius)",
maxSize: .init(width: 24, height: 20),
anchorPoint: .init(x: viewWireframe.rect.maxX + 10, y: viewWireframe.rect.maxY + 2),
color: AccentColor.green
)
}
}
/// 対象と外界のマージン描画用関数
private func drawOuterMargin(baseRect: CGRect, targetRect: CGRect) {
// 対象のフレームがベースのフレームを覆ってない場合は描画しない
if !targetRect.contains(baseRect) { return }
// 左端のマージン
drawHorizontalLine(startX: baseRect.minX, endX: targetRect.minX, positionY: baseRect.midY, color: AccentColor.blue, isDash: true, showsTooltip: true)
// 右端のマージン
drawHorizontalLine(startX: baseRect.maxX, endX: targetRect.maxX, positionY: baseRect.midY, color: AccentColor.blue, isDash: true, showsTooltip: true)
// 上端のマージン
drawVerticalLine(startY: baseRect.minY, endY: targetRect.minY, positionX: baseRect.midX, color: AccentColor.blue, isDash: true, showsTooltip: true)
// 下端のマージン
drawVerticalLine(startY: baseRect.maxY, endY: targetRect.maxY, positionX: baseRect.midX, color: AccentColor.blue, isDash: true, showsTooltip: true)
}
/// 縦線の描画用関数
private func drawVerticalLine(startY: CGFloat, endY: CGFloat, positionX: CGFloat, color: CGColor, isDash: Bool, showsTooltip: Bool) {
drawing(mode: .stroke) { context in
context.setStrokeColor(color)
context.setLineWidth(0.5)
if isDash {
context.setLineDash(phase: 0, lengths: [2.0, 2.0])
}
context.move(to: CGPoint(x: positionX, y: startY))
context.addLine(to: CGPoint(x: positionX, y: endY))
context.strokePath()
}
if showsTooltip {
let bottomText = numberFormatter.string(from: NSNumber(value: abs(endY - startY))) ?? ""
drawTextTooltip(
text: "\(bottomText)",
maxSize: .init(width: 40, height: 20),
anchorPoint: .init(x: positionX + 5, y: (startY + endY) / 2),
color: color
)
}
}
/// 横線の描画用関数
private func drawHorizontalLine(startX: CGFloat, endX: CGFloat, positionY: CGFloat, color: CGColor, isDash: Bool, showsTooltip: Bool) {
drawing(mode: .stroke) { context in
context.setStrokeColor(color)
context.setLineWidth(0.5)
if isDash {
context.setLineDash(phase: 0, lengths: [2.0, 2.0])
}
context.move(to: CGPoint(x: startX, y: positionY))
context.addLine(to: CGPoint(x: endX, y: positionY))
context.strokePath()
}
if showsTooltip {
let rightText = numberFormatter.string(from: NSNumber(value: abs(endX - startX))) ?? ""
drawTextTooltip(
text: "\(rightText)",
maxSize: .init(width: 40, height: 20),
anchorPoint: .init(x: (startX + endX) / 2, y: positionY - 25),
color: color
)
}
}
/// ツールチップの描画用関数
private func drawTextTooltip(text: String, maxSize: CGSize, anchorPoint: CGPoint, color: CGColor) {
drawing(mode: .fill) { context in
let attributedText = NSAttributedString(string: text, attributes: [
.foregroundColor: UIColor.white,
.font: UIFont.systemFont(ofSize: 8),
])
let typesetter = CTTypesetterCreateWithAttributedString(attributedText)
let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, attributedText.length))
let framesetter = CTFramesetterCreateWithAttributedString(attributedText)
let innerMargin: CGFloat = 4
let suggestFrameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: maxSize.width - innerMargin, height: maxSize.height - innerMargin), nil)
let baseSize = CGSize(width: suggestFrameSize.width + innerMargin * 2, height: suggestFrameSize.height + innerMargin * 2)
let frame = CGRect(x: anchorPoint.x - baseSize.width / 2, y: anchorPoint.y, width: baseSize.width, height: baseSize.height)
context.setFillColor(color)
context.addPath(UIBezierPath(roundedRect: frame, cornerRadius: 2).cgPath)
context.fillPath()
context.textPosition = .zero
context.textMatrix = .identity
context.translateBy(
x: frame.origin.x + (frame.width - suggestFrameSize.width) / 2,
y: frame.origin.y + (frame.height - suggestFrameSize.height) / 2 + suggestFrameSize.height
)
context.scaleBy(x: 1, y: -1)
CTLineDraw(ctLine, context)
}
}
/// 度→ラジアンへの変換用関数
private func radians(_ degrees: CGFloat) -> CGFloat {
let radian = (CGFloat.pi * 2) * (degrees / 360.0)
return radian
}
/// 描画用の関数 (他の描画箇所に影響を起こさないようにCGContextの状態をclosure内に留めるために用意しています)
private func drawing(mode: CGPathDrawingMode, closure: (_ context: CGContext) -> Void) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.saveGState()
closure(context)
context.restoreGState()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment