SwiftUI でツリー構造を描画するアプリを作ってみた
はじめに
こんにちは。今回は SwiftUI を使って「ツリー構造を描画・編集できるアプリ」を作ったので、その概要をまとめます。
コードの詳細はこのリポジトリから参照してください。
Xcode でプロジェクトを作成する手順
本プロジェクトは Xcode で作成しました。
プロジェクトの生成方法
-
Xcode で新規プロジェクトを作成
Xcode を起動し、「Create a new Xcode project」を選択します。 -
テンプレートで “App” を選択
「iOS」→「App」を選び、「Next」をクリックします。 -
プロジェクト設定
製品名を入力し、インターフェースとして「SwiftUI」、言語として「Swift」を選択して「Next」をクリック。最後に保存先を選んで「Create」を押します。
ディレクトリ構造について
新規に SwiftUI アプリプロジェクトを作成すると、以下のような構造が生成されます。
-
App フォルダ
アプリのメインコードやリソースを含む場所です。典型的には以下の 2 つのファイルが含まれます。-
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
ContentView
はView
プロトコルに準拠した構造体。SwiftUI では画面の UI を構造体で宣言的に作成し、body
で内容を定義します。 -
body: some View
SwiftUI の画面要素を宣言的に書き下します。この例ではVStack
で縦方向にImage
とText
を配置。 -
#Preview
Xcode のプレビュー機能でContentView
の表示をプレビューするための記述。
-
-
[プロジェクト名].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 シミュレータを追加できます。
- Xcode メニューから Window → Devices and Simulators を選択。
- 上部の「Simulators」タブを開き、左下の「+」ボタンなどからシミュレータを追加。
- 「OSVersion」で必要な OS バージョンを選び、もしリストに無ければ「Download more simulator runtimes」からダウンロード。
- ダウンロード完了後、任意のデバイスタイプを選んでシミュレータを登録。
開発のやり方
基本的な画面編集
新規プロジェクトのサンプルコード (ContentView.swift
) の body
内を編集し、UI を宣言的に構築します。VStack
や HStack
などのレイアウトコンテナや、Text
, Image
などの要素を組み合わせて画面を作ります。
画面を増やす
画面遷移を行いたい場合は、NavigationView
と NavigationLink
を使ってビュー階層を構築します。
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
各ディレクトリ・ファイルの役割
-
Hooks/
-
WidthForText.swift
- ノード名のテキスト幅を計算して返す関数を定義。
- ノード名に合わせて
TextField
の幅を最適化するために使っています。
-
WidthForText.swift
-
Models/
-
TreeNode.swift
- ツリーの各ノードを表すクラス。
name
,children
(子ノードの配列)、parent
(親ノードへの弱参照)などを保持。 -
@Published var name: String
などで、UI 側が変更を監視できるようになっています。
- ツリーの各ノードを表すクラス。
-
TreeData.swift
- 複数のツリー(ルートノード)をまとめて保持する
@Published var rootNodes: [TreeNode]
を持つ。 - アプリ全体の「ツリー管理」を行う中心クラス。
- 複数のツリー(ルートノード)をまとめて保持する
-
TreeNode.swift
-
Utilities/
-
Preferences.swift
- SwiftUI の
PreferenceKey
(NodeFramePreferenceKey
) を定義し、ノードの描画領域を一時的に保持できる仕組みを作っています。 - 後述する「線を引くアルゴリズム」で、親ノードの位置・子ノードの位置を取得するときに用います。
- SwiftUI の
-
Preferences.swift
-
Viwes/
-
ContentView.swift
- ホーム画面。ルートノードの追加・削除、一覧表示などを行う。
-
NavigationLink
でDetailView
に遷移し、各ツリーの編集を行う流れです。
-
DetailView.swift
- 選択したツリーを全体表示する画面。
-
ScrollView
&ZoomableView
でツリーを拡大縮小しながら閲覧できます。 - ここでもノード削除のロジックを持ち、親ノードの子配列から削除しています。
-
TitleInputModal.swift
- ホーム画面から新しいルートノードを追加するときに呼び出すモーダル。
- 入力されたタイトルで新しい
TreeNode
を作成してrootNodes
に追加します。
-
TreeView.swift
- ノードを再帰的に表示し、親ノードの下端から子ノードの上端へ線を引くロジックが入ったメインビュー。
- ノードのテキストフィールド、子ノードの追加ボタン、長押し削除などもここにまとまっています。
- 線を描画するアルゴリズム は本記事の後半で詳細を解説します。
-
ZoomableView.swift
- 拡大縮小やドラッグをサポートするラッパービュー。
MagnificationGesture
とDragGesture
をSimultaneousGesture
で組み合わせ、scale
とoffset
を変化させることでユーザがピンチイン・ドラッグをするとコンテンツをズーム&移動できるようにしています。
- 拡大縮小やドラッグをサポートするラッパービュー。
-
ContentView.swift
Swift 独特の文法の解説
ここでは、本アプリ内で登場する SwiftUI/Swift の文法のうち特徴的なものを簡単に解説します。
-
@State
,@StateObject
,@ObservedObject
-
@State
: View 内部で「値型の状態」を保持するために使います。View のライフサイクルとともに生き、変更時には画面が再描画されます。 -
@StateObject
:ObservableObject
のインスタンスを生成・管理する際に使います。ここではContentView
内でTreeData
を@StateObject
として保持し、配下の画面でもその変更を参照。 -
@ObservedObject
: View が外部から渡されるObservableObject
を監視する場合に使います。 例えばDetailView(node: TreeNode)
で受け取ったnode
を@ObservedObject var node: TreeNode
とすると、node
の変更を検知して画面を更新します。
-
-
ForEach
とIdentifiable
- SwiftUI の
ForEach
は、要素がIdentifiable
である必要があります。TreeNode
はIdentifiable
プロトコルを満たすためにlet id = UUID()
を持っており、これによってForEach(treeData.rootNodes) { node in ... }
のような書き方が可能です。
- SwiftUI の
-
GeometryReader
とPreferenceKey
- SwiftUI では「子ビューの座標やサイズを取得したい」ときに
GeometryReader
とPreferenceKey
を組み合わせる手法があります。 - 普通、親ビューから子ビューの正確な座標を取得するのは難しいのですが、
anchorPreference
やoverlayPreferenceValue
を駆使して、後述のように線を描画しています。
- SwiftUI では「子ビューの座標やサイズを取得したい」ときに
-
@Binding
- ある View が別の View から「状態の参照・更新」を受け継ぐ場合に使う。ここでは、
TreeView
が@Binding var selectedNode: TreeNode?
を受け取り、長押しされたときにselectedNode = node
などと書き換えています。
- ある View が別の View から「状態の参照・更新」を受け継ぐ場合に使う。ここでは、
線を引く部分のアルゴリズム詳細
今回の一番面白いところが、「親ノードの下端から子ノードの上端に向けて線を引く」というアルゴリズムです。
TreeView
のコードを抜粋して解説します。
anchorPreference
でノードのフレーム情報を収集
1. 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
を通じて上位ビュー(親ビュー)に集約されます。
overlayPreferenceValue
で親子ノードの座標を計算し、線を引く
2. .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.midX
とparentRect.maxY
は「親ノードの中心 X 座標」「見た目の下端 Y 座標」を示します。 -
childRect.midX
とchildRect.minY
は「子ノードの中心 X 座標」「見た目の上端 Y 座標」。 - これらを
Path
で結ぶことで、「親ノード下端 → 子ノード上端」 の直線を引いています。
この仕組みによって、ツリーを再帰的に配置しても、各ノードの表示位置を取得して動的に線を描画できるわけです。
なお、ビルドする場合は、Xcode で project/
ディレクトリを開いて ⌘R
を押すだけでシミュレータ上で動作確認できます。
今後の展望
本アプリはデータを保持していないので、アプリを落とすと作ったツリー構造が全て飛びます。単に軽くツリー構造作るくらいの目的なので現状は特に問題ないですが、誰かに共有したいとか、メモとして残していきたいってなったら、Swift Data や Core Data, Realm を導入する必要がありそうです。Realm に関しては後から導入できますが、Swift Data や Core Data に関しては、プロジェクト作成時にデータベースを選ぶ必要があると思います。
以上です
Discussion