SwiftUIで階層構造を表すリストを自作する
概要
SwiftUIで階層構造を表したリストを作る場合、Listにこのようなイニシャライザが生えているので、これらを利用すると思います。
ただSwiftUIで提供されているコンポーネントでは、chevronの向きを変えたり、itemの間隔を変更したりなど、微妙なカスタマイズができません。
そこで今回は階層構造を表すリストを自作してみます。
ソースコードはgithubにも上げています。
要件
今回カスタマイズしたいポイントは以下の3つです。
- chevronの向きを変える(アコーディオンを閉じているときは下向き、開いているときは上向き)
- アイテム同士の間隔を大きくする
- リーフのViewに区切り線をつけない
以下のような動作を目指します。
完成形のコードは以下のようになっています。
(コピペで即座に実行できるよう、プレビューなど関係ないコードも含まれています。)
AccordionView.swift
import SwiftUI
fileprivate struct Padding {
static var small: CGFloat = 8
}
fileprivate extension Image {
static let chevronDown = Self(systemName: "chevron.down")
static let chevronUp = Self(systemName: "chevron.up")
}
struct Keyword: Identifiable, Hashable {
var id: String { name }
var name: String
var children: [Keyword]?
static let samples: [Keyword] = [
.init(name: "1", children: [.init(name: "1-1", children: [.init(name: "1-1-1", children: nil), .init(name: "1-1-2", children: nil), .init(name: "1-1-3", children: nil)]), .init(name: "1-2", children: [.init(name: "1-2-1", children: nil)])]),
.init(name: "2", children: [.init(name: "2-1", children: nil)]),
.init(name: "3", children: [.init(name: "3-1", children: nil)]),
]
}
struct AccordionView<Data, RowContent>: View where
Data : RandomAccessCollection,
Data.Element: Identifiable,
Data.Element: Equatable,
RowContent: View
{
@State private var openedNodes: [Data.Element] = []
var data: Data
var children: KeyPath<Data.Element, Data?>
var leadingPadding: CGFloat
var selection: (Data.Element) -> ()
var rawContent: (Data.Element) -> RowContent
init(
_ data: Data,
children: KeyPath<Data.Element, Data?>,
leadingPadding: CGFloat = Padding.small,
selection: @escaping (Data.Element) -> (),
@ViewBuilder rawContent: @escaping (Data.Element) -> RowContent
) {
self.data = data
self.children = children
self.leadingPadding = leadingPadding
self.selection = selection
self.rawContent = rawContent
}
var body: some View {
VStack(spacing: Padding.small) {
ForEach(data) { item in
HStack {
rawContent(item)
.onTapGesture {
selection(item)
}
.padding(.leading, leadingPadding)
.frame(maxWidth: .infinity, alignment: .leading)
if let _ = item[keyPath: children] {
Button {
withAnimation {
if openedNodes.contains(item) {
if let index = openedNodes.firstIndex(of: item) {
openedNodes.remove(at: index)
}
} else {
openedNodes.append(item)
}
}
} label: {
openedNodes.contains(item)
? Image.chevronUp.padding(.trailing, Padding.small)
: Image.chevronDown.padding(.trailing, Padding.small)
}
.foregroundColor(Color(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.6)))
}
}
.padding(.bottom, item[keyPath: children] == nil ? Padding.small : 0)
if let _ = item[keyPath: children] {
Divider()
.padding(.leading, leadingPadding)
}
if let child = item[keyPath: children], openedNodes.contains(item)
{
AccordionView(
child,
children: children,
leadingPadding: self.leadingPadding + Padding.small,
selection: { self.selection($0) }
) { item in
rawContent(item)
}
}
}
}
}
}
struct AccordionView_Previews: PreviewProvider {
static var previews: some View {
VStack {
AccordionView(
Keyword.samples,
children: \.children,
selection: { _ in }
) { item in
Text(item.name)
}
}
}
}
解説
上記のコードを元に解説していきます。
イニシャライザ
init<Data, RowContent>(
_ data: Data,
children: KeyPath<Data.Element, Data?>,
leadingPadding: CGFloat = Padding.small,
selection: @escaping (Data.Element) -> (),
@ViewBuilder rawContent: @escaping (Data.Element) -> RowContent
) where
Data: RandomAccessCollection,
Data.Element: Identifiable,
Data.Element: Equatable,
RowContent: View
data
Identifiable
に準拠した、リストを計算するためのコレクションです。
children
子のデータにアクセスするためのKeyPathです。
データがnil以外の場合は子を持つことができるノードを表し、
データがnilの場合は子をを持たないリーフであることを表します。
KeyPathにしている理由として、引数で渡されたData
のノードにアクセスしなければなりませんが、Data
は総称型となっているため、どのようなプロパティを持っているかAccordionView
側では分かりません。
そのため動的にプロパティにアクセスしたいためKeyPathとしています。
leadingPadding
階層が深くなるにつれてleadingのパディングを大きくしたいため、引数として渡すようにしています。
ただ基本的に内部のみで引数として渡すので、AccordionView
のインスタンス生成時には意識する必要はありません。
selection
ノードのタップイベントのコールバックです。
rawContent
リストの単一行のViewを生成するViewBuilderです。
Body
次にBodyを見ていきますが、そんなに大したことはしていません。
アイテムビュー
リストの単位行を表すViewです。渡されたrawContent
にタップジェスチャー、パッディングをつけているだけです。
rawContent(item)
.onTapGesture {
selection(item)
}
.padding(.leading, leadingPadding)
.frame(maxWidth: .infinity, alignment: .leading)
Chevron
ノードを展開しているかしていないかに応じて矢印の向きを変えています。
展開しているノードはopenedNodes
という配列に格納していき、そこに含まれている場合は展開している、含まれていない場合は展開していないことを表しています。
// `item[keyPath: children]`でプロパティにアクセスし、nilでなければリーフではないのでchevronを表示する。nilであればリーフなのでchevronを表示しない。
if let _ = item[keyPath: children] {
Button {
withAnimation {
// 配列に含まれているので展開済み
if openedNodes.contains(item) {
if let index = openedNodes.firstIndex(of: item) {
// 展開されているのノードを閉じる
openedNodes.remove(at: index)
}
} else {
// ノードを展開する
openedNodes.append(item)
}
}
} label: {
// chevronの向きを変更
openedNodes.contains(item)
? Image.chevronUp.padding(.trailing, Padding.small)
: Image.chevronDown.padding(.trailing, Padding.small)
}
}
Divider
リーフかそうでないかに応じて、区切り線を表示するか決定しているだけです。
if let _ = item[keyPath: children] {
Divider()
.padding(.leading, leadingPadding)
}
Node
リーフでない且つまだ展開されていない場合は、ノードを表示するために再帰的にAccordionView
インスタンスを生成しています。
if let child = item[keyPath: children], openedNodes.contains(item) {
AccordionView(
child,
children: children,
leadingPadding: self.leadingPadding + Padding.small,
selection: { self.selection($0) }
) { item in
rawContent(item)
}
}
まとめ
意外と簡単に見た目はそれっぽくなりました。
Discussion