iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作る

こんにちは、クックパッドマートプロダクト開発部の佐藤(@n_atmark)です。

普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。

動作している物を見ていただくのが分かりやすいと思うので、早速ですが動作イメージがこちらになります。

フレームインスペクタの動作の様子
フレームインスペクタの動作の様子 (gif)

アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマージンを表示するようにしています。

開発の背景

私が普段開発に従事しているクックパッドマートiOSアプリでは元々5の倍数マージンを採用していたのですが、これを4の倍数マージンに変えたいという背景がありました。

後発のクックパッドマートAndroidアプリで4の倍数マージンを採用しており、デザイナーが画面デザインを作成するために5の倍数マージン / 4の倍数マージンを切り替えてデザインを作らないといけないという課題があり、どちらかに統一したいという要望がありました。クックパッド社内の他のiOSアプリでも4の倍数マージンを採用していることもあり、クックパッドマートiOSアプリも4の倍数マージンに合わせることになりました。

しかし、マージン値を機械的に置き換えるのは難しく、現在は気づいた箇所から徐々に置き換えていく方針で進めています。 クックパッドマートiOSアプリにはUIKit (AutoLayout) で作られた画面もあればSwiftUIで作られた画面もあり、マージンを直接設定している箇所や変数に置いている箇所、アニメーションのためにマージン値を切り替えている箇所などがあるため、統一した方法で置き換えができないためです。また、10ptの箇所を8ptに置き換えるべきか12ptに置き換えるべきかといった問題もあります。

そこで、マージンの違いに気づきやすくするという目的で今回のツールを開発しました。 QA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みとして用意しています。

実装の紹介

実装の全体は https://gist.github.com/natmark/ef27845aff19059e74916df421223b79 に置いてあります。

final class DebugFrameInspectorView: UIView {
   init() {
        super.init(frame: .zero)
        backgroundColor = .clear
        isUserInteractionEnabled = false
    }
   // … 略
}

DebugFrameInspectorView がマージンやフレームサイズを表示しているViewです。 backgroundColor = .clear かつ isUserInteractionEnabled = false なViewとなっていて、これを keyWindow に対して addSubView(_:) して利用してもらう想定です。

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)
}

setup() メソッドの中で UILongPressGestureRecognizerUIWindow に追加して長押しのジェスチャーを補足できるようにしています。

@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()
     }
 }

 @objc private func didDoubleLongPress(_ sender: UILongPressGestureRecognizer) {
    // 略
 }

長押し時の処理がこちらになります。1本指で長押しするか2本指で長押しするかによって didSingleLongPress(_:) didDoubleLongPress(_:) と実装を分けていますが、内容としてはほぼ同じ処理になります。

let positionInWindow = sender.location(in: window)keyWindow 内におけるタッチ位置を取得した後 hitTest(_:with:) を用いてタップされたViewを特定しています。 (let hitView = window?.hitTest(positionInWindow, with: nil) の箇所)

その後タップされたViewのframe (superviewを基準にした相対位置) を keyWindow 内における座標に変換したいので、hitView 内でのタッチ位置の座標を取得し ( let positionInHitView = sender.location(in: hitView) の箇所) 、 positionInWindow から positionInHitView の座標分ずらすことで keyWindow 内における座標を取得しています。

let globalRect = CGRect(
   x: positionInWindow.x - positionInHitView.x,
   y: positionInWindow.y - positionInHitView.y,
   width: hitView.frame.size.width,
   height: hitView.frame.size.height
)

singlePressValuedoublePressValue という変数に値を保持しておいて setNeedsDisplay() を呼び出すことで draw(_:) メソッドを呼び出し、フレーム境界やマージンなどの線の描画を行っています。

線の描画に関しては詳しく触れませんが、CoreGraphicsを用いてゴリゴリ記述しています。

利用方法

/// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }

    let window: UIWindow
    // 以下はアプリの構成によって変わるので必要箇所だけ入れてください
    if let keyWindow = windowScene.keyWindow {
        // keyWindowがある場合 (SwiftUI.App利用時)
        window = keyWindow
    } else if let firstWindow = windowScene.windows.first {
        // keyWindowはないが、windowが存在する場合 (Main Storyboard利用時)
        window = firstWindow
        window.makeKeyAndVisible()
    } else {
        // window自体存在しない場合 (Storyboard不使用時)
        window = UIWindow(windowScene: windowScene)
        window.makeKeyAndVisible()
        window.rootViewController = MyRootViewController()
        self.window = window
    }

    #if DEBUG
    let debugFrameInspectorView = DebugFrameInspectorView()
    window.addSubview(debugFrameInspectorView)
    debugFrameInspectorView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        window.leadingAnchor.constraint(equalTo: debugFrameInspectorView.leadingAnchor),
        window.trailingAnchor.constraint(equalTo: debugFrameInspectorView.trailingAnchor),
        window.topAnchor.constraint(equalTo: debugFrameInspectorView.topAnchor),
        window.bottomAnchor.constraint(equalTo: debugFrameInspectorView.bottomAnchor),
    ])
    debugFrameInspectorView.setup()
    #endif
}

keyWindow となるUIWindowに対して addSubView(_:) した後、 setup() を呼び出すことで利用できます。

デバッグ用の機能なのでCompilation conditionsを使ってbuild configurationが DEBUG の時のみ有効にするなどしておくことをオススメします。

実際に開発したツールを使ってもらって

開発の背景の箇所にもある通り、今回のツールの目的としてはQA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みを用意することでした。

チームメンバーに実際に使ってもらうと、以下のようなコメントをもらいました。

ポジティブな意見

  • (エンジニア) 実際のアプリのマージンを実装を見に行かなくても確かめられるのは嬉しい
  • (デザイナー) Webだとインスペクタですぐ要素を確認できるのにネイティブアプリだと見ることができないので、アプリでもフレーム値を確認できるのがありがたい

追加要望

  • (デザイナー) 実装されているフォントサイズや色も確認できると嬉しい

ネガティブな意見

  • (エンジニア) ボタンの領域を長押しした時に、ボタン内の要素のフレームサイズが見れない
  • (エンジニア) フレームサイズが実装を行った際に意図したサイズと違って表示されることがある

実装されたアプリ上でマージン値をサクッと確かめられることに対して一定効果がありそうなことが分かりました。 また、フォントサイズや色も確認できるとアプリに実装されたデザインが正しいかどうかを確認する用途で、より便利に使えそうです。

一方で、前後に重なった要素に対してはフレームサイズの確認がうまくできないという課題や、実際の実装値と表示される値が違うことがあるという課題も浮き彫りになりました。

実装が難しい部分の紹介

ここまで開発したフレームインスペクタの紹介をしましたが、できないことも結構あります。 先ほどチームメンバーから要望のあった「実装されているフォントサイズや色」といった要素や、「実装値と表示値が違う」という課題の解決も現状難しいと感じている点です。

その難しさの元になるのがSwiftUIで実装された画面です。

UIKitとSwiftUIで以下のような画面を実装したとします。

UIKit

SwiftUI

UIWindowsubViews を辿って階層構造を表示するとそれぞれ下のようになります。

UIKit SwiftUI
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ UIView
      └ UIStackView
      ├ UIStackView
      │ ├ UIImageView ()
      │ └ UILabel (タイトル)
      └ UILabel (テキストテキストテキスト)
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

UIKitの場合は UIImageViewUILabel といったお馴染みのViewクラスなので、 UIView 型の subview をキャストすれば簡単にプロパティを確認できますが、SwiftUIの場合はSwiftUIのView構造ではなく、描画用のクラスである SwiftUI._UIGraphicsViewSwiftUI.DisplayList.ViewUpdater.Platform.CGDrawingView といったprivateなクラスが利用されます。

これが一つ目の難しいポイントで、SwiftUIでViewを組み立てた時にどういったModifierが適用されたかといった情報を持たないため、これらからフォントサイズや設定された色を取り出すことは困難です。

また、UIKitではViewの階層構造が保持されるのに対して、SwiftUIでは _UIHostingView というクラスの配下にフラットに展開されてしまいます。 SwiftUIのViewを組み立てる時に利用した VStackHStack は、フレームの決定だけに用いられUIViewの世界においては現れません。 これが二つ目の難しいポイントです。

VStackHStack は、フレームの決定だけに用いられUIViewの世界においては現れない」というのがどう難しさにつながっているのか説明するために以下の図を用意してみました。

UIKit SwiftUI

これは UIWindowsubviews を辿ってそれぞれの CALayer に対して枠線を表示したものです。

SwiftUI側の実装で、「タイトル」および「テキストテキストテキスト」には .frame(maxWidth: .infinity, alignment: .leading) をつけているものの CGDrawingView が表現するフレームはテキストが文字幅に縮んでしまっていることがわかると思います。

元々VStackに設定していた .padding(.horizontal, 20) のうち、trailing 側に関しては正しく効いているかどうか、今回のフレームインスペクタでは上手く確認することができません。

補足

ちなみに VStack および HStack.background(Color.white) を追加するとVStack/HStackの領域が描画され、下のように画面幅にフレームが広がっているのを確認できます。

View構造 フレームレイアウト
UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView (HStackのbackground)
    ├ _UIGraphicsView (VStackのbackground)
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

今後の展望

今回作ったフレームインスペクタではSwiftUIで作った画面表示にまだ課題があることを紹介しました。 ところでXcodeには Debug View Hierarchy というデバッグ機能があることはよく知られていると思います。

この Debug View Hierarchy を用いるとSwiftUIで開発された画面に関しても Horizontal StackText といったSwiftUIで組み立てたViewの構造や、テキストのフォントといったModifierも確認することができます。

どうにか Debug View Hierarchy で表示しているような情報を取得できると、今回開発したフレームインスペクタに機能追加することができそうです。

_UIHostingView._viewDebugData()

SwiftUI._UIHostingView に非公開APIとして _viewDebugData() というメソッドが存在します。これを用いるとデバッグ用にSwiftUIのView構造を解析できそうです。 (https://apurin.me/articles/swiftui-secrets/ を参考にさせていただきました)

SwiftUIのView構造をもう一度示すのですが

UIWindow
└ UITransitionView
  └ UIDropShadowView
    └ _UIHostingView<ModifiedContent<AnyView, RootModifier>>
    ├ _UIGraphicsView ()
    ├ CGDrawingView (タイトル)
    └ CGDrawingView (テキストテキストテキスト)

階層を辿ることで _UIHostingView を取得できそうです。

struct ViewDebugData {
    let data: [_ViewDebug.Property: Any]
    let childData: [ViewDebugData]
}
protocol DebuggableSwiftUIView {
    func viewDebugData() -> [ViewDebugData]
}
extension _UIHostingView: DebuggableSwiftUIView {
    func viewDebugData() -> [ViewDebugData] {
        let _viewDebugData = _viewDebugData()
        return unsafeBitCast(_viewDebugData, to: [ViewDebugData].self)
    }
}

DebuggableSwiftUIView というプロトコルを用意して _UIHostingView に準拠させています。

private func digDebuggableSwiftUIView(from view: UIView) -> (any DebuggableSwiftUIView)? {
     if let debuggableView = view as? DebuggableSwiftUIView {
         return debuggableView
     } else {
         for subView in view.subviews {
             if let debuggableView = debuggableView(from: subView) {
                 return debuggableView
             }
         }
         return nil
     }
}

DebuggableSwiftUIViewに準拠したViewを探索するメソッドを用意して

guard let window else { return }
let viewDebugData = digDebuggableSwiftUIView(from: window)?.viewDebugData()
print(viewDebugData)

viewDebugData() を呼び出すことで、_UIHostingView._viewDebugData() の結果を確認できそうです。

_viewDebugData() の出力はかなり大きいので出力の全体は https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118 に置いておきます。

SwiftUI._ViewDebug.Property.type の部分だけ階層表示すると

_SafeAreaInsetsModifier
  └ ModifiedContent, EditModeScopeModifier>, HitTestBindingModifier>
    └ HitTestBindingModifier
      └ ModifiedContent, EditModeScopeModifier>
        └ EditModeScopeModifier
          └ ModifiedContent<_ViewModifier_Content, TransformModifier>
            └ TransformModifier
              └ _ViewModifier_Content
                └ ModifiedContent
                  └ RootModifier
                    └ ModifiedContent, RootEnvironmentModifier>, PresentedSceneValueInputModifier>
                      └ PresentedSceneValueInputModifier
                        └ ModifiedContent<_ViewModifier_Content, RootEnvironmentModifier>
                          └ RootEnvironmentModifier
                            └ _ViewModifier_Content
                              └ AnyView
                                └ ContentView
                                  └ ModifiedContent>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>, _PaddingLayout>
                                    └ _PaddingLayout
                                      └ VStack>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>
                                        └ Tree<_VStackLayout, TupleView<(HStack>>, _FrameLayout>, ModifiedContent)>>, ModifiedContent)>>
                                          ├ _FlexFrameLayout
                                          ├  └ Text
                                          ├    └ AccessibilityStyledTextContentView
                                          ├      └ ModifiedContent, AccessibilityLargeContentViewModifier>
                                          ├        └ AccessibilityLargeContentViewModifier
                                          ├          └ ModifiedContent
                                          ├            └ AccessibilityAttachmentModifier
                                          ├              └ StyledTextContentView
                                          └ HStack>>, _FrameLayout>, ModifiedContent)>>
                                            └ Tree<_HStackLayout, TupleView<(ModifiedContent>>, _FrameLayout>, ModifiedContent)>>
                                              ├ _FlexFrameLayout
                                              ├  └ Text
                                              ├    └ AccessibilityStyledTextContentView
                                              ├      └ ModifiedContent, AccessibilityLargeContentViewModifier>
                                              ├        └ AccessibilityLargeContentViewModifier
                                              ├          └ ModifiedContent
                                              ├            └ AccessibilityAttachmentModifier
                                              ├              └ StyledTextContentView
                                              └ _FrameLayout
                                                └ Image
                                                  └ ModifiedContent
                                                    └ AccessibilityAttachmentModifier
                                                      └ Resolved

のようになっており、Viewの階層構造が取れそうです。 また、SwiftUI._ViewDebug.Property.type: SwiftUI.Text の箇所だけピックアップしてみると

SampleSwiftUIView.ViewDebugData(
  data: [
    SwiftUI._ViewDebug.Property.value: SwiftUI.Text(
        storage: SwiftUI.Text.Storage.anyTextStorage(<LocalizedTextStorage: 0x00006000017f54a0>: \"テキストテキストテキスト\"), 
        modifiers: [SwiftUI.Text.Modifier.font(Optional(SwiftUI.Font(provider: SwiftUI.(unknown context at $105d9f910).FontBox<SwiftUI.Font.(unknown context at $105e5aae8).SystemProvider>)))]
    ), 
    SwiftUI._ViewDebug.Property.type: SwiftUI.Text, 
    SwiftUI._ViewDebug.Property.size: (191.0, 20.333333333333332), 
    SwiftUI._ViewDebug.Property.position: (20.0, 442.5)
  ],
  childData: [] // 略
)

Modifierに関しても確認することができました。

_viewDebugData の内容を実際にデバッグツールに活用しようと思うとかなり骨が折れる作業になりそうですが、SwiftUIのView構造をここまで詳細に取得できれば、Viewのデバッグツールを作るにあたってできることは広がりそうです。

まとめ

今回、アプリに実装されたUI要素のフレームサイズやマージンを簡単に確認できるツールを作って紹介しました。

チームメンバーに使ってもらって、サクッとフレームサイズやマージンを確認する用途であれば便利に使えそうなことが分かった一方で、より完成度の高いツールを目指そうとした際に、SwiftUI製のViewのインスペクタ実装は難しい部分が多いことも分かりました。

_UIHostingView のprivate APIである _viewDebugData() を使うと詳細なSwiftUIのView構造を利用できそうなこと分かったため、 _viewDebugData() のデータを活用したデバッグツールの改善に関しても引き続き検討してみようと考えています。

今回の記事が快適なデバッグ環境構築の参考になれば嬉しいです。