見出し画像

SwiftUI の Disclosure・Outline・List

SwiftUI にはツリー構造のデータを表示するビューがあります。

この記事ではWWDC2020の『Stacks, Grids, and Outlines in SwiftUI』(日本語字幕あり)後半の DisclosureGroup と OutlineGrounp や List を解説します。
ツリー構造についても実例で説明します。

このビデオの前半に対応する内容は『Lazy な Stack と Grid』に書きました。


・画像クリックで拡大表示できます
・画像を拡大表示中は画像の左右をクリックで画像だけを順に表示できます
・ソースコード部分は左右にスクロールできます

画面は Xcode 13 を使った実行結果です。
サンプルはすべて Xcode の iOS用プレイグラウンド書類用コードです。(iPad や Mac の Playgrounds 3.4.1 アプリでも直接入力し実行できます)
この記事の最後の有料部分にあるリンクから完全なサンプルをダウンロードできます。


🟧1 コンテンツの表示/非表示

引数のビューの表示/非表示を実行時に切り替えることができるのが DisclosureGroup です。

DisclosureGroup も SwiftUI のビューで iOS 14 以降で利用可能です。
DisclosureGroup のドキュメントにあるコードを Playground 用にしました。

【サンプル01】


import SwiftUI
import PlaygroundSupport

struct Disclosure: View {
   struct ToggleStates {
       var oneIsOn: Bool = false
       var twoIsOn: Bool = true
   }
   @State private var toggleStates = ToggleStates()
   @State private var topExpanded: Bool = true
   var body: some View {
       DisclosureGroup("Items", isExpanded: $topExpanded) {
           Toggle("Toggle 1", isOn: $toggleStates.oneIsOn)
           Toggle("Toggle 2", isOn: $toggleStates.twoIsOn)
           DisclosureGroup("Sub-items") {
               Text("Sub-item 1")
           }
       }
//        .padding()
   }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   Disclosure()
      .frame(width: 300, height: 500) // XcodeのPlaygroundで必要
)

DisclosureGroup のラベル部分をタップするとコンテンツを表示非表示します。

画像1

ビューの高さが変わるのでラベル部分の位置が変わります。(全体を中央に表示するためです)

画像2

より実用的にするには下にSpacer()を配置しました。
(ほかは同一なのでbodyのみ示します)

【サンプル02】

var body: some View {
   DisclosureGroup("Items", isExpanded: $topExpanded) {
       Toggle("Toggle 1", isOn: $toggleStates.oneIsOn)
       Toggle("Toggle 2", isOn: $toggleStates.twoIsOn)
       DisclosureGroup("Sub-items") {
           Text("Sub-item 1")
       }
   }
   .padding()   // 👈

   Spacer()     // 👈

}

.padding() で余白もつけた場合の実行画面です。

画像3

開閉しても items の位置はかわりません。

ところで上記のコードは body 内に複数のビューを配置しています。
なぜこの記述でエラーにならないか調べ【SwiftUIのbodyが少し変わっていた】に書きました。


🟧1-1 DisclosureGroup

DisclosureGroup型を確認しましょう。
ディスクロージャーコントロールの状態に応じて、別のコンテンツビューを表示・非表示するビューです。

struct DisclosureGroup<Label, Content> where Label : View, Content : View
ここでまぎらわしいのは Label です。
iOS 14から Label ビューが利用可能になりましたが、DisclosureGroup の Label はビューなら何でも構わないことをあらわすための ジェネリック の型です。

Label ビューは SF Symbols など画像と文字テキストを1行に表示するビューです。

DisclosureGroup はふたつの状態を持ちます。
“expanded”(展開)は開いた状態で、コンテンツを表示し操作可能にします。
“collapsed”(折りたたまれた状態)では DisclosureGroup のラベル部分だけを表示します。

最初のサンプルコードはこのドキュメント(DisclosureGroup型)のコードのPlayground用です。


🟧1-2 DisclosureGroup イニシャライザー

DisclosureGroup のイニシャライザーは複数あり、二つのグループに分かれています。
最初のグループは文字列ラベルによるものです。
4つありますが、実質的には二つです。
ラベルの指定がローカライズキーか文字列かの違いと、展開状態のバインディングを持つか持たないかの組み合わせです。

ローカライズキーを指定する DisclosureGroup イニシャライザー
init(_:content:)

【サンプル 01DisclosureGroup】二つ目の DisclosureGroup(ラベルが"Sub-items"のもの)がこのイニシャライザーを使っています。
ローカライズキーを使用して、ラベル部分に Text ビューを作成し、DisclosureGroup を作成します。

init(_ titleKey: LocalizedStringKey,
 content: @escaping () -> Content)

titleKey
DisclosureGroup の内容を説明するローカライズキー文字列です。
ローカライズテーブルに一致する文字がない場合はキー文字列をそのまま表示します。

content
DisclosureGroup を展開したときに表示されるコンテンツ。
サンプルコードのようにトレイリングクロージャーとして引数カッコの外に書かかれることが多い。


ローカライズキーを指定し展開状態のバインディングを持つ DisclosureGroup イニシャライザー
init(_:isExpanded:content:)

【サンプル 01DisclosureGroup】の最初の DisclosureGroup がこのイニシャライザーを使っています。

ローカライズキーを使用して、ラベルのテキストビューを作成し、展開状態(展開または折りたたまれた状態)へのバインディングを使用して、DisclosureGroup を作成します。

init(_ titleKey: LocalizedStringKey,
 isExpanded: Binding<Bool>, 
 content: @escaping () -> Content)

titleKey
DisclosureGroup の内容を説明するローカライズキー文字列です。
ローカライズテーブルに一致する文字がない場合はキー文字列をそのまま表示します。

isExpanded
グループの展開状態(展開されているか、折りたたまれているか)を決定するブール値へのバインディングです。
このバインディングで実行時に展開した状態にするかどうかを指定できます。

content
DisclosureGroup の展開時に表示されるコンテンツです。
サンプルコードのようにトレイリングクロージャーとして引数カッコの外に書かかれることが多い。


🟧1-3 任意ラベルのイニシャライザー

こちらは展開状態のバインディングを持つか持たないかの違いのみです。
ラベルには引数で与えたビューをそのまま使います。
展開状態を示すコントロールの外見や挙動は変わりません。

ラベルとして任意のビューを指定する DisclosureGroup イニシャライザー
init(content:label:)

ラベル部分に表示する指定されたビューとコンテンツビューを持つDisclosureGroup を作成します。

init(content: @escaping () -> Content,
 label: () -> Label)

ここで Label は where Label : View なのでビューなら何でも使えます 
Label型インスタンスには限りません。

content
DisclosureGroup を展開したときに表示されるコンテンツ。

label
DisclosureGroup の内容を説明するビューです。


ラベルとして任意のビューを指定し展開状態のバインディングを持つ DisclosureGroup イニシャライザー
init(isExpanded:content:label:)

ラベル部分に表示する指定されたビューとコンテンツビュー、および展開状態(展開または折りたたまれた状態)へのバインディングを持つDisclosureGroup を作成します。

init(isExpanded: Binding<Bool>,
 content: @escaping () -> Content, 
 label: () -> Label)

isExpanded
グループの展開状態(展開されているか、折りたたまれているか)を決定するブール値へのバインディングです。

content
DisclosureGroup が展開したときに表示されるコンテンツです。

label
DisclosureGroup のコンテンツを説明するビューです。

【サンプル 03DisclosureGroup】

import SwiftUI
import PlaygroundSupport

struct Disclosure: View {
   struct ToggleStates {
       var oneIsOn: Bool = false
       var twoIsOn: Bool = true
   }
   @State private var toggleStates = ToggleStates()
   @State private var topExpanded: Bool = true
   var body: some View {
       DisclosureGroup(isExpanded: $topExpanded) {
           Toggle("Toggle 1", isOn: $toggleStates.oneIsOn)
           Toggle("Toggle 2", isOn: $toggleStates.twoIsOn)
           DisclosureGroup {
               Text("Sub-item 1")
           } label: {
               HStack {
                   Label("オプション", systemImage: "gearshape.2")
                   Color.yellow.frame(width: 60)
               }
           }
       } label: {
           Label("設定", systemImage: "gearshape")
       }
       .padding()
       Spacer()
   }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   Disclosure()
      .frame(width: 300, height: 500) // XcodeのPlaygroundで必要
)

label:引数は任意のビューを設定できますがここでは Label("設定", systemImage: "gearshape") としています。
歯車アイコンと「設定」の文字列です。
もうひとつはHStackでLabelビューと黄色の四角形をレイアウトしています。
このようにどんなビューでもラベルとして指定できます。
ここでは余白を確保するため .padding() を追加しています。

画像15

二つ目の DisclosureGroup は HStack で Label と 黄色の長方形を表示しています。
@State private var topExpanded: Bool = true の部分を false にすると実行時に閉じた状態になります。


🟧1-4 コンテナビューにより表示が変わる

同じコードでも Form などのコンテナビュー内にあると見かけが変わります。

【サンプル 04DisclosureGroup】

import SwiftUI
import PlaygroundSupport

struct Disclosure: View {
   struct ToggleStates {
       var oneIsOn: Bool = false
       var twoIsOn: Bool = true
   }
   @State private var toggleStates = ToggleStates()
   @State private var topExpanded: Bool = true
   var body: some View {
       Form {
           DisclosureGroup(isExpanded: $topExpanded) {
               Toggle("Toggle 1", isOn: $toggleStates.oneIsOn)
               Toggle("Toggle 2", isOn: $toggleStates.twoIsOn)
               DisclosureGroup {
                   Text("Sub-item 1")
               } label: {
                   HStack {
                       Label("オプション", systemImage: "gearshape.2")
                       Color.yellow.frame(width: 60)
                   }
               }
           } label: {
               Label("設定", systemImage: "gearshape")
           }
       }
   }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   Disclosure()
      .frame(width: 300, height: 500) // XcodeのPlaygroundで必要
)

.padding()とSpacer()なしに標準的な設定画面の外見になります。
コンテンツは自動で字下げ表示され、包括関係が明確になります。

画像15


コンテナビューを Form から List に変更してみましょう。
変更箇所は1箇所ですが外見がかわります。

【サンプル 05DisclosureGroup】

import SwiftUI
import PlaygroundSupport

struct Disclosure: View {
   struct ToggleStates {
       var oneIsOn: Bool = false
       var twoIsOn: Bool = true
   }
   @State private var toggleStates = ToggleStates()
   @State private var topExpanded: Bool = true
   var body: some View {
       List {   // 👈
           DisclosureGroup(isExpanded: $topExpanded) {
               Toggle("Toggle 1", isOn: $toggleStates.oneIsOn)
               Toggle("Toggle 2", isOn: $toggleStates.twoIsOn)
               DisclosureGroup {
                   Text("Sub-item 1")
               } label: {
                   HStack {
                       Label("オプション", systemImage: "gearshape.2")
                       Color.yellow.frame(width: 60)
                   }
               }
           } label: {
               Label("設定", systemImage: "gearshape")
           }
       }
   }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   Disclosure()
      .frame(width: 300, height: 500) // XcodeのPlaygroundで必要
)

Xcode での実行画面です。

画像15

DisclosureGroup の説明はここまでです。
次に階層構造(ツリー構造)を持つ OutlineGrounp を解説します。
その前に『ツリー構造』とデータの説明を少ししましょう。


🟧2 ツリー構造のデータ

OutlineGrounp と 最後に説明する List を利用するには『ツリー構造』のデータが必要です。
「ツリー構造」はソフトウェアの専門用語のひとつなので、用語解説を引用します。

上記IT用語辞典 e-Words より引用:
「木構造とは、データ構造の一つで、一つの要素(ノード)が複数の子要素を持ち、一つの子要素が複数の孫要素を持ち、という形で階層が深くなるほど枝分かれしていく構造のこと。
木が幹から枝、枝から葉に分岐していく様子に似ているためこのように呼ばれる。」

ウィキペディアへのリンク『木構造 (データ構造)』も参考にしてください。
こちらには『ノード間の関係は家系図に見立てた用語で表現される。』と書かれています。

実際にはシンプルなので心配はいりません。

木構造を構成する要素はノード(node:節)と呼ばれ、
ノード同士は親子関係を持ち、
親のないノードを(root)ノード、
子のないノードを(leaf)ノードと呼びます。

具体的には 例❶ 日本の自治体 都道府県に分かれていて、各都道府県には市町村があります。
一部の市には区を持つものもあります。
日本が(root)、北海道や東京都がノード(node:節)、千歳市や小笠原村が(leaf)です。

例❷ Appleの製品 Mac、iPad、iPhoneなどにわかれていて、それぞれさらにいくつかの製品にわかれています。

これをコードで扱うには一つのstruct型と配列を使います。
例えば製品データには最小限次のコードが必要です。

struct Product {
   var name: String        // member がnilなら製品名、複数ありならカテゴリー名
   var member: [Product]?  // Product型インスタンスの配列 カテゴリーの製品数に制限はない
}
説明のための最もシンプルな例です。
実用的にはさらに情報をを持ちます。(例:価格、リリース年、対応OSバージョンなど)
項目が増えるとイニシャライザーでの設定項目もふええます。
ツリー構造はこのようなシンプルな型で実現できます。
このデータを List などで利用する場合には準拠しなければならないプロトコルがあります。

メンバー名 name は製品名またはカテゴリー名です。
メンバー名 member は配列です。
配列 member の各要素の型は Product(自分自身)で、「値なし」の状態で葉ノードを区別するためオプショナルです。
自身の型が Product の場合は [Product]? です。

例えばカテゴリーがiPhoneで、とりあえず2機種登録した場合は

let iPhone13 = Product(name: "iPhone 13")
let iPhone12 = Product(name: "iPhone 12")
let iPhone: [Product] = [ Product(name: "iPhone", member: [iPhone13, iPhone12]) ]

となります。

Product(name: "iPhone 13") はデフォルトのイニシャライザーで、memberは省略し nil となります。
iPhone13 とiPhone12 は Product型インスタンスで、iPhone は [Product] 型インスタンスです。

階層が深くなっても一つの型を使うところがミソです。
このため階層により各メンバーの意味や内容が変わる場合があります。
例:name は カテゴリー名 または 製品名

一部の階層でしか使わないデータもプロパティとして追加が必要です。

ツリー構造データのサンプルです。

【サンプル 10tree】

import Foundation

/// 製品カテゴリーと製品名のための型
struct Product {
   var name: String        // member がnilなら製品名、複数ありならカテゴリー名
   var member: [Product]?  // Product型インスタンスの配列 カテゴリーの製品数に制限はない
}

let iPhone13Pro = Product(name: "iPhone 13 Pro")
let iPhone13 = Product(name: "iPhone 13")
let iPhone12 = Product(name: "iPhone 12")
let iPhoneSE = Product(name: "iPhone SE")
let iPhone: [Product] = [ Product(name: "iPhone", member: [iPhone13Pro, iPhone13, iPhone12, iPhoneSE]) ]

for item in iPhone {
   if let products = item.member {
       print("【\(item.name)】 count=\(products.count)")
       for subitem in products {
           print("・・\(subitem.name)")
       }
   }
}

このコードを実行すると

【iPhone】 count=4
・・iPhone 13 Pro
・・iPhone 13
・・iPhone 12
・・iPhone SE

を Xcode なら debug area に、Playgroundsアプリなら コンソール に表示します。


🟧2-1 階層データの処理

このサンプルのようにデータ階層が子ノードまでの場合は二重のループ処理ですべてのデータを処理できます。
孫ノードもある場合は三重のループ処理が必要です。

このコードでは let iPhone13 = Product(name: "iPhone 13") などそれぞれのインスタンスを一度定数に代入していますが、配列リテラルとして記述することが多いと思います。
このコードの場合は先頭の import Foundation は記述しなくてもエラーにはなりません。

Macなどのデータも追加しましょう。

【サンプル 11tree】

import Foundation

struct Product {
   var name: String
   var member: [Product]?
}

let iPhone13Pro = Product(name: "iPhone 13 Pro")
let iPhone13 = Product(name: "iPhone 13")
let iPhone12 = Product(name: "iPhone 12")
let iPhoneSE = Product(name: "iPhone SE")
let iPhone: [Product] = [
   iPhone13Pro,
   iPhone13,
   iPhone12,
   iPhoneSE]

let mac: [Product] = [
   Product(name: "MacBook Air"),
   Product(name: "MacBook Pro"),
   Product(name: "iMac"),
   Product(name: "Mac Pro"),
   Product(name: "Mac mini")
]

let apple: [Product] = [ Product(name: "iPhone", member: iPhone),
                        Product(name: "Mac", member: mac),
                        Product(name: "Car", member: []),
                        Product(name: "iPod touch", member: nil)]

for item in apple {
   if let products = item.member {
       print("【\(item.name)】 count=\(products.count)")
       for subitem in products {
           print("・・\(subitem.name)")
       }
   } else {
       print("\(item.name) ####")
   }
}
ここでは Product 型配列とわかりやすくするために iPhone13Pro などの変数(定数)を使っています。
実用的には mac のようにすべて配列リテラルに描くことが多い。

カテゴリー MaciPhone はそれぞれ複数の製品データを持ちます。
一方カテゴリー Car はまだ製品がないので空の配列で、iPod touch は現在単独の製品なのでmember は nil です(nameだけの指定でも可能ですがここでは明示しました)。
表示のための繰り返し処理に .member が nil の場合も追加しています。

このコードを実行すると

【iPhone】 count=4
・・iPhone 13 Pro
・・iPhone 13
・・iPhone 12
・・iPhone SEMaccount=5
・・MacBook Air
・・MacBook Pro
・・iMac
・・Mac Pro
・・Mac mini
【Carcount=0
iPod touch ####

を Xcode なら debug area に、Playgroundsアプリなら コンソール に表示します。

"iPhod touch" は iPod カテゴリを作りその中の要素とすることもできますが、子を持つデータと同じ階層にも置けること示すサンプルデータとしました。


🟧2-2 ツリー構造データの作成

サンプルではすべてのデータは配列リテラルで設定しました。

実用的なコードでは、与えられたデータを(二重や三重の)ループ処理や再帰処理で同じ階層のデータをまとめ親ノードに追加していき、最終的にツリー構造のデータを作ります。

私の作った『絵文字アナライザ』アプリでは、入力した文字列のコードを分解しツリー構造にしています。

画像16

AllGlyphs(オールグリフス)』アプリでは、フォントファミリー別にフォント名をツリー構造にしています。

どちらも無料で確認できます。


🟧3 ツリー構造に対応する表示

OutlineGroup はツリー構造に対応したSwiftUIのビューです。
さっそくサンプルで表示を確認しましょう。

【サンプル 20OutlineGroup】

import SwiftUI
import PlaygroundSupport

struct Product: Identifiable {
   var name: String
   var member: [Product]?
   var id = UUID()
   
   var description: String {
       switch member {
       case nil:
           return "\(name) 🌿"
       case .some(let children):
           return children.isEmpty ? "\(name) 🈳" : "\(name) 🪵"
       }
   }

}

let apple: [Product] = [
   Product(name: "iPhone", member: [
       Product(name: "iPhone 13 Pro"),
       Product(name: "iPhone 13"),
       Product(name: "iPhone 12"),
       Product(name: "iPhone SE")
   ]),
   Product(name: "Mac", member: [
       Product(name: "Mac mini"),
       Product(name: "MacBook Air"),
       Product(name: "MacBook Pro"),
       Product(name: "iMac"),
       Product(name: "Mac Pro")
   ]),
   Product(name: "Car", member: []),
   Product(name: "iPhod touch")
]

struct Outline: View {
   var body: some View {
       OutlineGroup(apple, children: \.member) { item in
           Text("\(item.description)")
       }
   }
}

子の情報を持つ場合は(件数がゼロでも)展開状態を表すインジケーター > がつきます。
内部で DisclosureGroup を利用していると予想できます。
この情報がnilの場合はこのインジケーターを表示しません。

このサンプルでは絵文字でも区別しています。
ひとつ以上の子を持つ場合は「🪵」、ゼロの場合は「🈳」、子の情報がnilの場合は「🌿」を表示します。
対応するコードは Product型の descriptionプロパティです。

Xcode で実行した画面です。

画像7

展開結果を比較すると

画像8

親ノード(子の数がゼロでも)は幅いっぱいに表示し、葉ノードはセンタリングで表示されています。

表示する要素は Identifiable プロトコル準拠が前提の OutlineGroup イニシャライザーを使ったので struct Product: Identifiable { として var id = UUID() プロパティを追加しています。

各行に表示するデータは description プロパティの文字列を使っています。description プロパティは、name プロパティの文字列に加えて階層に対応した絵文字付きで文字列を返します。

ここから先は

14,856字 / 7画像 / 1ファイル
この記事のみ ¥ 500

今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。