⚒️

SwiftUIで階層構造を表すリストを自作する

2021/12/29に公開

概要

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

まとめ

意外と簡単に見た目はそれっぽくなりました。

参考URL

GitHubで編集を提案

Discussion