🦁

SwiftUI でツリー構造を描画するアプリを作ってみた

2025/01/09に公開

はじめに

こんにちは。今回は SwiftUI を使って「ツリー構造を描画・編集できるアプリ」を作ったので、その概要をまとめます。
コードの詳細はこのリポジトリから参照してください。

Xcode でプロジェクトを作成する手順

本プロジェクトは Xcode で作成しました。

プロジェクトの生成方法

  1. Xcode で新規プロジェクトを作成
    Xcode を起動し、「Create a new Xcode project」を選択します。
  2. テンプレートで “App” を選択
    「iOS」→「App」を選び、「Next」をクリックします。
  3. プロジェクト設定
    製品名を入力し、インターフェースとして「SwiftUI」、言語として「Swift」を選択して「Next」をクリック。最後に保存先を選んで「Create」を押します。

ディレクトリ構造について

新規に SwiftUI アプリプロジェクトを作成すると、以下のような構造が生成されます。

  • App フォルダ
    アプリのメインコードやリソースを含む場所です。典型的には以下の 2 つのファイルが含まれます。

    1. ContentView.swift
      アプリの最初に表示されるメイン画面(View)の定義が含まれています。下記のような初期コードが自動生成されます。

      import SwiftUI
      
      struct ContentView: View {
          var body: some View {
              VStack {
                  Image(systemName: "globe")
                      .imageScale(.large)
                      .foregroundStyle(.tint)
                  Text("Hello, world!")
              }
              .padding()
          }
      }
      
      #Preview {
          ContentView()
      }
      

      コード解説

      • import SwiftUI
        SwiftUI フレームワークをインポートしています。
      • struct ContentView: View
        ContentViewView プロトコルに準拠した構造体。SwiftUI では画面の UI を構造体で宣言的に作成し、body で内容を定義します。
      • body: some View
        SwiftUI の画面要素を宣言的に書き下します。この例では VStack で縦方向に ImageText を配置。
      • #Preview
        Xcode のプレビュー機能で ContentView の表示をプレビューするための記述。
    2. [プロジェクト名].swift
      アプリ全体のエントリーポイントとなるファイルです。@main 付きの構造体が App プロトコルに準拠し、アプリのライフサイクルを管理します。初期生成コードはおおむね次のとおりです。

      import SwiftUI
      
      @main
      struct TestAppApp: App {
          var body: some Scene {
              WindowGroup {
                  ContentView()
              }
          }
      }
      

      コード解説

      • @main
        この構造体がアプリの入り口 (App プロトコル) になることを示します。
      • WindowGroup { ContentView() }
        アプリ起動時に表示する画面を指定しています。ここでは先述の ContentView をルート画面として表示。
  • Tests フォルダ
    アプリの単体テストを行うためのファイルを含む場所です。自動生成される XCTest ベースのファイルが配置されます。

  • UITests フォルダ
    アプリのUI テスト(ユーザーインターフェーステスト)を行うためのファイルを含む場所です。ユーザー操作をシミュレートして画面の挙動を検証できます。


シミュレータの追加

デフォルトのシミュレータ以外でテストしたい場合、以下の手順で新しい iOS シミュレータを追加できます。

  1. Xcode メニューから Window → Devices and Simulators を選択。
  2. 上部の「Simulators」タブを開き、左下の「+」ボタンなどからシミュレータを追加。
  3. 「OSVersion」で必要な OS バージョンを選び、もしリストに無ければ「Download more simulator runtimes」からダウンロード。
  4. ダウンロード完了後、任意のデバイスタイプを選んでシミュレータを登録。

開発のやり方

基本的な画面編集

新規プロジェクトのサンプルコード (ContentView.swift) の body 内を編集し、UI を宣言的に構築します。VStackHStack などのレイアウトコンテナや、Text, Image などの要素を組み合わせて画面を作ります。

画面を増やす

画面遷移を行いたい場合は、NavigationViewNavigationLink を使ってビュー階層を構築します。

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                Text("This is the main view")
                    .padding()

                NavigationLink(destination: DetailView()) {
                    Text("Go to Detail View")
                        .foregroundColor(.blue)
                        .padding()
                        .background(Color.gray.opacity(0.2))
                        .cornerRadius(8)
                }
            }
            .navigationTitle("Main View")
        }
    }
}

struct DetailView: View {
    var body: some View {
        Text("This is the detail view")
            .navigationTitle("Detail View")
    }
}

コードのポイント

  • NavigationView
    画面を階層的に管理するためのコンテナ。NavigationLink で新しい画面への遷移が可能に。
  • NavigationLink(destination:)
    遷移先ビューを指定し、タップ (またはクリック) で画面を push する仕組みを提供。
  • navigationTitle("Main View")
    ナビゲーションバーにタイトルを設定し、戻るボタンにも反映されます。

ファイル分割

画面が増える場合は、DetailView.swift のように別ファイルへ切り出しましょう。切り出した関数などは、プロジェクトフォルダ内にあるなら import せずに使えます。これ凄い。


SwiftUI インスペクター

一応開発者ツールみたいなのもあります。

  • UI ベース
    command + control を押しながら画面上の要素をクリックし、「Show SwiftUI Inspector…」を選択すると UI を直接編集するためのポップオーバーが表示されます。
  • コードベース
    control キーを押しながら宣言部分をクリックしても「Show SwiftUI Inspector…」が選択でき、コードと連動したインスペクター画面が使えます。

アプリの概要

  • ツリー構造
    アプリのホーム画面で複数のルートノードを追加し、それぞれノードの配下に子ノードを追加していくことができます。
  • 編集方法
    • ノード名は直接テキストを編集。
    • 長押しで削除アラートが表示され、OK すると削除。
    • プラスボタンを押して新しい子ノードを追加。
  • ズーム&ドラッグ
    詳細画面 (DetailView) ではピンチイン・ピンチアウトでの拡大縮小、およびドラッグでの全体移動が可能。
  • 線の描画
    親ノードの下端から子ノードの上端へ向かってラインを引き、ツリー構造を作れるようにしています。

ディレクトリ構造

project/
├ Hooks/
│   └ WidthForText.swift
├ Models/
│   ├ TreeData.swift
│   └ TreeNode.swift
├ Utilities/
│   └ Preferences.swift
└ Viwes/
    ├ ContentView.swift
    ├ DetailView.swift
    ├ TitleInputModal.swift
    ├ TreeView.swift
    └ ZoomableView.swift

各ディレクトリ・ファイルの役割

  1. Hooks/

    • WidthForText.swift
      • ノード名のテキスト幅を計算して返す関数を定義。
      • ノード名に合わせて TextField の幅を最適化するために使っています。
  2. Models/

    • TreeNode.swift
      • ツリーの各ノードを表すクラス。name, children(子ノードの配列)、parent(親ノードへの弱参照)などを保持。
      • @Published var name: String などで、UI 側が変更を監視できるようになっています。
    • TreeData.swift
      • 複数のツリー(ルートノード)をまとめて保持する @Published var rootNodes: [TreeNode] を持つ。
      • アプリ全体の「ツリー管理」を行う中心クラス。
  3. Utilities/

    • Preferences.swift
      • SwiftUI の PreferenceKey (NodeFramePreferenceKey) を定義し、ノードの描画領域を一時的に保持できる仕組みを作っています。
      • 後述する「線を引くアルゴリズム」で、親ノードの位置・子ノードの位置を取得するときに用います。
  4. Viwes/

    • ContentView.swift
      • ホーム画面。ルートノードの追加・削除、一覧表示などを行う。
      • NavigationLinkDetailView に遷移し、各ツリーの編集を行う流れです。
    • DetailView.swift
      • 選択したツリーを全体表示する画面。
      • ScrollView & ZoomableView でツリーを拡大縮小しながら閲覧できます。
      • ここでもノード削除のロジックを持ち、親ノードの子配列から削除しています。
    • TitleInputModal.swift
      • ホーム画面から新しいルートノードを追加するときに呼び出すモーダル。
      • 入力されたタイトルで新しい TreeNode を作成して rootNodes に追加します。
    • TreeView.swift
      • ノードを再帰的に表示し、親ノードの下端から子ノードの上端へ線を引くロジックが入ったメインビュー。
      • ノードのテキストフィールド、子ノードの追加ボタン、長押し削除などもここにまとまっています。
      • 線を描画するアルゴリズム は本記事の後半で詳細を解説します。
    • ZoomableView.swift
      • 拡大縮小やドラッグをサポートするラッパービュー。MagnificationGestureDragGestureSimultaneousGesture で組み合わせ、scaleoffset を変化させることでユーザがピンチイン・ドラッグをするとコンテンツをズーム&移動できるようにしています。

Swift 独特の文法の解説

ここでは、本アプリ内で登場する SwiftUI/Swift の文法のうち特徴的なものを簡単に解説します。

  1. @State, @StateObject, @ObservedObject

    • @State: View 内部で「値型の状態」を保持するために使います。View のライフサイクルとともに生き、変更時には画面が再描画されます。
    • @StateObject: ObservableObject のインスタンスを生成・管理する際に使います。ここでは ContentView 内で TreeData@StateObject として保持し、配下の画面でもその変更を参照。
    • @ObservedObject: View が外部から渡される ObservableObject を監視する場合に使います。 例えば DetailView(node: TreeNode) で受け取った node@ObservedObject var node: TreeNode とすると、node の変更を検知して画面を更新します。
  2. ForEachIdentifiable

    • SwiftUI の ForEach は、要素が Identifiable である必要があります。TreeNodeIdentifiable プロトコルを満たすために let id = UUID() を持っており、これによって ForEach(treeData.rootNodes) { node in ... } のような書き方が可能です。
  3. GeometryReaderPreferenceKey

    • SwiftUI では「子ビューの座標やサイズを取得したい」ときに GeometryReaderPreferenceKey を組み合わせる手法があります。
    • 普通、親ビューから子ビューの正確な座標を取得するのは難しいのですが、anchorPreferenceoverlayPreferenceValue を駆使して、後述のように線を描画しています。
  4. @Binding

    • ある View が別の View から「状態の参照・更新」を受け継ぐ場合に使う。ここでは、TreeView@Binding var selectedNode: TreeNode? を受け取り、長押しされたときに selectedNode = node などと書き換えています。

線を引く部分のアルゴリズム詳細

今回の一番面白いところが、「親ノードの下端から子ノードの上端に向けて線を引く」というアルゴリズムです。
TreeView のコードを抜粋して解説します。

1. anchorPreference でノードのフレーム情報を収集

ZStack {
    nodeContent
        .background(
            GeometryReader { geo in
                Color.clear
                    .anchorPreference(
                        key: NodeFramePreferenceKey.self,
                        value: .bounds
                    ) { anchor in
                        [NodeFramePreferenceData(id: node.id, frame: anchor)]
                    }
            }
        )
}
  • ZStack の中で nodeContent(ノードのテキストフィールドやボタンが入った部分)を表示しつつ、GeometryReader を使って bounds アンカーを取得しています。
  • anchorPreference(key:value:)NodeFramePreferenceData を作り、その中に (id, frame) を格納。
  • これにより、「このノードの表示範囲はどこか」という情報が PreferenceKey を通じて上位ビュー(親ビュー)に集約されます。

2. overlayPreferenceValue で親子ノードの座標を計算し、線を引く

.overlayPreferenceValue(NodeFramePreferenceKey.self) { preferences in
    GeometryReader { geometry in
        ForEach(node.children) { child in
            if let parentData = preferences.first(where: { $0.id == node.id }),
               let childData  = preferences.first(where: { $0.id == child.id }) {

                let parentRect = geometry[parentData.frame]
                let childRect  = geometry[childData.frame]

                Path { path in
                    let parentBottom = CGPoint(
                        x: parentRect.midX,
                        y: parentRect.maxY
                    )
                    let childTop = CGPoint(
                        x: childRect.midX,
                        y: childRect.minY
                    )

                    path.move(to: parentBottom)
                    path.addLine(to: childTop)
                }
                .stroke(Color.gray, lineWidth: 1)
            }
        }
    }
}
  • SwiftUI では一度集約された PreferenceKey の値を、overlayPreferenceValue で受け取り、最終的な描画を行います
  • preferences.first(where: { $0.id == node.id }) で親ノードのフレーム、child.id で子ノードのフレームを取り出し、それぞれを geometry[...] で座標系に変換します。
  • parentRect.midXparentRect.maxY は「親ノードの中心 X 座標」「見た目の下端 Y 座標」を示します。
  • childRect.midXchildRect.minY は「子ノードの中心 X 座標」「見た目の上端 Y 座標」。
  • これらを Path で結ぶことで、「親ノード下端 → 子ノード上端」 の直線を引いています。

この仕組みによって、ツリーを再帰的に配置しても、各ノードの表示位置を取得して動的に線を描画できるわけです。


なお、ビルドする場合は、Xcode で project/ ディレクトリを開いて ⌘R を押すだけでシミュレータ上で動作確認できます。

今後の展望

本アプリはデータを保持していないので、アプリを落とすと作ったツリー構造が全て飛びます。単に軽くツリー構造作るくらいの目的なので現状は特に問題ないですが、誰かに共有したいとか、メモとして残していきたいってなったら、Swift Data や Core Data, Realm を導入する必要がありそうです。Realm に関しては後から導入できますが、Swift Data や Core Data に関しては、プロジェクト作成時にデータベースを選ぶ必要があると思います。
 
 
 
 
以上です

KA projects

Discussion