見出し画像

Lazy な Stack と Grid

SwiftUI には『Lazy』がつく Stack と Grid ビューがあります。
この記事では『Lazy』の有無による違いと Grid の使い方について解説します。

WWDC2020の『Stacks, Grids, and Outlines in SwiftUI』(日本語字幕あり)でも解説されていますが、さらに詳しく比較のコードとともに取り上げました。

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

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


1 Stack

Stack は SwiftUI のビューをレイアウトするビューです。
Stack については『SwiftUIの画面レイアウト 前編』を参照してください。

通常の HStack はビューを水平方向にレイアウトし、VStack は垂直方向にレイアウトします。
どちらもスクロール機能は持ちません。
レイアウトするビューが多い場合は ScrollView と組み合わせます。


1-1 シンプルな例

Playgroundsアプリや Xcode の Playground で確認できるサンプルです。

import SwiftUI
import PlaygroundSupport


//: ❶ Playground でもここから実行を開始する
let dateFormatter = DateFormatter()

struct StackView: View {
   var body: some View {
       ScrollView {
           VStack {
               ForEach(0..<100) {
                   TText($0)
                       .font(.largeTitle)
                       .frame(height:200)  // 高さを大きくして画面内に表示される件数を減らす
               }
           }
       }
   }
   
   // 引数と現在時刻を表示する
   func TText(_ index:Int) -> some View {
       Text("\(index)    \(dateFormatter.string(from: Date()))")
   }
}

// フォーマッターの準備
//: ❷ 次にここが実行される
dateFormatter.locale = Locale(identifier: "ja_JP")
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .medium

// playgroundで実行する場合に必要なコード
//: ❸ ここが実行されライブビューに表示する
PlaygroundPage.current.setLiveView(
   StackView()
       .frame(width: 400, height: 500) // XcodeのPlaygroundで必要
)

実行すると 0 から 99 の番号と時刻を表示します。

Playgroundsアプリでは「”結果”を有効にする」をオフにして実行してください。
オンのまま実行すると各行の結果を保存するため数秒かかる場合があります。

画像23

最後までスクロールするとすべて同じ時刻であることが確認できます。

画像23

このサンプルでわかることは画面上に見えているかどうかにかかわらず、StackViewを表示する時にすべてのTText関数が返す Text ビューが作られる です。

WWDCの解説ではサンドイッチの画像をStackで表示していました。
すべての画像を読み込みすべてのビューを表示するので処理時間がかかりメモリーも消費します

TText(_:) メソッドは Text インスタンスを返す関数です。
引数の番号とこのメソッドの実行時刻を表示します。

TText(_:) メソッドのようにカスタムビューを定義するほどでもない場合、引数がある場合は関数引数がない場合は計算型プロパティで some View を返すとコードがすっきりします。
必要により HStack や VStack などを使うことでレイアウトも自在です。

ForEach(0..<100) { について
範囲を指定する場合 0..<100 は問題ありませんが 0...99 ではエラーになります。
ForEach(0...99, id:\.self) { と記述するとエラーは出ません。


1-2 DateFormatterについて

DateFormatter は Foundationフレームワークの日付と時刻表示のための class です。
各国の書式にも対応できます。
class なので let でもプロパティの変更が可能です。

let dateFormatter = DateFormatter() でインスタンスを初期化します。

dateFormatter.locale = Locale(identifier: "ja_JP") 日本語表記のためにLocaleを設定します。
dateFormatter.dateStyle = .none 日付表示はなしに設定しています。
dateFormatter.timeStyle = .medium 時:分:秒の表示を設定しています。

timeStyle は .none で何も表示しない、.short で時:分のみ表示、.long では日本の場合は JST を時:分:秒の後ろに表示、.full では漢字で表示しますのでそれぞれ切り替えて確認してください。

string(from:) インスタンスメソッドで引数の日時データをフォーマット処理した文字列に変換します。
dateFormatter.string(from: Date()) は Date 型のイニシャライザー Date() で現在日時を渡しています。

DateFormatter を使うことで実用的な日時表示が可能です。

簡単なサンプルです。
現在日時 now をそのまま表示する場合とフォーマットした場合を比較してください。

import Foundation

let now = Date()

print("\(now)") // 2021-10-05 10:20:21 +0000 などと表示

let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "ja_JP")
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .medium

print("\(dateFormatter.string(from: now))")

Xcode で実行すると Debug area に

2021-10-05 10:20:21 +0000
19:20:21

のような表示になります。


1-3 LazyVStack

次にまったく同じコードで VStackLazyVStack に変更して実行しましょう。

ソースコードは割愛します(VStackLazyVStack に変更するだけです)

実行した日時を表示するのはかわりませんが、スクロールすると0番の時刻と違いがあります。

画像24

この例では画面キャプチャを作成していたので数秒たってからスクロールを開始しました。
スクロール操作により見えるようになった Text ビューだけが作られます
これが『Lazy』の違いです。

ただし一旦表示されると、その後に日時の表示が更新されることはありません

LazyVStack

struct LazyVStack<Content> where Content : View

イニシャライザー init(alignment:spacing:pinnedViews:content:)

init(alignment: HorizontalAlignment = .center,
 spacing: CGFloat? = nil,
 pinnedViews: PinnedScrollableViews = .init(),
 content: () -> Content)

イニシャライザーはcontentのほかにデフォルト設定済みの三つの引数があります。

alignment
水平方向の揃え指定で、デフォルトは .center です。

spacing
間隔です。
デフォルトはnil、nilではデフォルトの間隔になります。
間隔が不要の場合はゼロを設定してください。

pinnedViews
ピン留めされる子画面の種類です。
デフォルトではからの状態です。
sectionFooters や sectionHeaders を指定できます。

content
レイアウトするビューを指定します。
通常はトレイリングクロージャー形式で引数カッコの直後に出して記述します。


LazyHStack

struct LazyHStack<Content> where Content : View

イニシャライザー init(alignment:spacing:pinnedViews:content:)

init(alignment: VerticalAlignment = .center,
 spacing: CGFloat? = nil,
 pinnedViews: PinnedScrollableViews = .init(),
 content: () -> Content)

イニシャライザの引数は alignment が VerticalAlignment である以外は LazyVStack と同じです。


1-4 公式ドキュメントのサンプル

公式ドキュメントの Grouping Data with Lazy Stack Views(英文)にあるサンプルコードを少し修正してみました。

LazyVStack 内に Section を含む例です。
サンプルでは色を表示するだけですが、日時表示を追加し(一度に表示されるビューを減らすために)ビューの高さもふやしフッターも追加しています。

画像23

Playgroundsで動くようにしたコードです。

import SwiftUI
import PlaygroundSupport

let dateFormatter = DateFormatter()

struct ColorData: Identifiable {
   let id = UUID()
   let name: String
   let color: Color
   let variations: [ShadeData]
   
   struct ShadeData: Identifiable {
       let id = UUID()
       var brightness: Double
   }
   
   init(color: Color, name: String) {
       self.name = name
       self.color = color
       self.variations = stride(from: 0.0, to: 0.15, by: 0.03)
           .map { ShadeData(brightness: $0) }
   }
}

struct ColorSelectionView: View {
   let sections = [
       ColorData(color: .red, name: "Reds"),
       ColorData(color: .green, name: "Greens"),
       ColorData(color: .blue, name: "Blues")
   ]
   
   var body: some View {
       ScrollView {
           LazyVStack(spacing: 1, pinnedViews: [.sectionHeaders]) {
               ForEach(sections) { section in
                   Section(header: SectionHeaderView(colorData: section), footer:Text("=== \(section.name) footer ===").font(.headline)) {
                       ForEach(section.variations) { variation in
                           section.color
                               .brightness(variation.brightness)
                               .frame(height: 100)
                               .overlay(TText())
                       }
                   }
               }
           }
       }
   }
   
   // 現在時刻を表示する
   func TText() -> some View {
       Text("\(dateFormatter.string(from: Date()))")
           .font(.largeTitle)
   }
}

struct SectionHeaderView: View {
   var colorData: ColorData
   
   var body: some View {
       HStack {
           Text(colorData.name)
               .font(.headline)
               .foregroundColor(colorData.color)
           Spacer()
       }
       .padding()
       .background(Color.primary
                       .colorInvert()
                       .opacity(0.75))
   }
}

// フォーマッターの準備
dateFormatter.locale = Locale(identifier: "ja_JP")
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .medium

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

Section 内のビューも Section が LazyVStack 内にあれば「Lazy」な挙動になることが確認できます。

LazyVStack(spacing: 1, pinnedViews: [.sectionHeaders]) { の部分でセクションヘッダーを固定するように pinnedViews 引数で指定しています。

ドキュメントのサンプルにオーバーレイで現在時刻のみ表示する TText ビューを重ねています。
(日時文字列が見やすいように ColorData の色合いも変更しています)

このコードでは 1-1 と同じ関数名 TText() で番号表示がないので引数なしにしています。


1-5 もうひとつの Stack 関連ドキュメント

もうひとつの公式ドキュメント Creating Performant Scrollable Stacks(英文)は効率よく大量のビューを表示する場合の対策や最適化のポイントが書かれています。

通常の Stack は『子ビューを一度に読み込むため、レイアウトが高速で信頼性の高いものとなる』、Lazy stack は『ブビューが表示されたときにのみシステムがジオメトリを計算するので、レイアウトの正確さとパフォーマンスをある程度交換することができる』と書かれています。

使用するスタックビューのタイプを選択する際には、常に標準スタックビューから開始し、コードのプロファイリングによりパフォーマンスの向上が認められた場合にのみ、Lazy stack に切り替えてください。
と書かれています。

原文:When choosing the type of stack view to use, always start with a standard stack view and only switch to a lazy stack if profiling your code shows a worthwhile performance improvement.

Lazy な stack はパフォーマンスを優先するためにレイアウトの精度が犠牲なることが明記されています。

このドキュメントには、実際にパフォーマンスをプロファイルで確認するよう書かれています。
パフォーマンス測定はシミュレーターではなく実際のデバイスを使うように明示されています。
Instruments ツールの使用方法ドキュメントへのリンクもあります。

「コードのプロファイリングによりパフォーマンスの向上が認められた場合にのみ、Lazy stack に切り替えてください」と書かれていたのはちょっと意外でした。


1-6 コンテンツにあわせてコンテナビューを選ぶ

さらにもうひとつのドキュメント Picking Container Views for Your Content (英文)は Stack だけでなくビューをレイアウトする Swift UI のコンテナビューについて解説しています。

画像6

スタック、グリッド、リスト、フォームなどについて書かれています。
概要の説明とほかのドキュメントへのリンクもあるのでぜひ参照してください。

List  はスクロール機能を持ち、もともと Lazy な挙動であることが書かれています。
iOS 14 から利用可能になった LazyVGrid と LazyHGrid も Layzy のみです。

順序の変更や項目の削除などの操作が必要な場合は Stack よりも List が適していることが書かれています。


2 グリッド Grid

LazyVGrid と LazyHGrid はコンテンツを二次元にレイアウトするコンテナビューです。
特に画面が大きくなった場合に柔軟な表示ができます。

8月にリリースしたアプリ AllGlyphs(オールグリフス)で利用しています。

フォントが持つ全ての文字(グリフ)を LazyVGrid を使って表示する SwiftUI アプリです。
フォントによりグリッドで表示するビューの高さや幅がかわります。

画像7

全て同じサイズのビューを表示します。
画面を回転させると各表示のサイズはほぼ同じで水平方向に表示できる数が変わります。

画像10


画像8

画像9

フォントは固定幅とグリフにより幅が違うもの(プロポーショナル)があり、幅が違う場合は最大に合わせてビューの幅を決めています。
(上の画面ではローマ数字Ⅷが最大幅です)

LazyVGrid と LazyHGrid はスクロールの機能は持ちません。
必要な場合は ScrollView と組み合わせます。

グリッドは WWDC 2020 で発表され iOS 14 から利用可能になりました。
概要は冒頭で紹介した Stacks, Grids, and Outlines in SwiftUI(日本語字幕あり)で説明されています。

SwiftUI のグリッドは2021年現在 LazyVGrid と LazyHGrid の二つだけで、Lazyではないタイプは提供されていません。
水平と垂直の二つがあるのは Stack と同じです。

iOS 14 で AllGlyphs(オールグリフス)を実行するとグリフ数の多いフォントでスクロール範囲が狭くなり一部を表示できない場合がありました。
余白を追加して対応しましたが、iOS 15 ではこの現象には遭遇していません。
iOS 15 ではLazy動作でのスクロール範囲精度低下は改善されたようです。


2-1 GridItem

グリッドを2次元にするための指定は GridItem型を使います。
LazyVGrid では水平方向の配置、LazyHGrid では垂直方向の配置を GridItem型インスタンスで指定します。

柔軟な指定が可能ですが、初見ではどのように使うか予想がつきにくいかもしれませんので順に説明します。

GridItem

struct GridItem

イニシャライザー init(_:spacing:alignment:)

init(_ size: GridItem.Size = .flexible(),
 spacing: CGFloat? = nil,
 alignment: Alignment? = nil)

size
グリッドアイテムのサイズ(幅または高さ)です。
GridItem.Size 型インスタンスで指定します。
デフォルトは flexible です。

spacing
この項目と次の項目の間に使用する間隔。
省略するとデフォルトの間隔になります。

alignment
このグリッドアイテムに使用するアラインメント。

すべての引数でデフォルトが設定されているので省略可能です。

イニシャライザーの size 引数に指定する GridItem.Size 型が柔軟な設定を実現します。
GridItem.Size.fixed(_:)、GridItem.Size.flexible(minimum:maximum:)、GridItem.Size.adaptive(minimum:maximum:)の三種類あります。

fixed
固定サイズのひとつのアイテムを指定します。
サイズ指定が必要です。

case fixed(CGFloat)

flexible
可変サイズのひとつのアイテムを指定します。
サイズは最小値と最大値で指定します。
デフォルトでは最小は10ポイント、最大は .infinity(無限大)です。

case flexible(minimum: CGFloat = 10, maximum: CGFloat = .infinity)
CGFloat.infinity は正の数の無限大を表す static var です。

adaptive
スペースにできるだけ(表示数可変)アイテムを表示する指定です。
表示スペースに何個のアイテムを表示可能か自動で決定し表示します。
最小サイズは必ず設定が必要です。
最大サイズは .infinity(無限大)がデフォルトで指定を省略できます。

case adaptive(minimum: CGFloat, maximum: CGFloat = .infinity)

基本的にはグリッドの表示サイズをminimumのサイズで割った個数表示可能です。
minimumが50で表示サイズが120なら(ビューの間隔を無視すると)2つ表示できます。
表示個数が決まったら実際の幅はmaximumで指定したサイズ以下に決まります。


2-2 GridItem の使い分け

カラム※の表示数が固定なら fixed か flexible を使い、行または列の幅または高さに収まる範囲でできるだけ多く表示するなら adaptive を使います。

※ LazyVGrid なら カラム数、LazyHGrid なら 行数

fixed のみを使うとカラムの表示のサイズと個数を決定できます。
表示範囲が小さすぎる場合はスクロールなどの対策が必要です。

ひとつの flexible を使うと LazyVGrid(垂直)なら List と同じような表示になります。

ひとつまたは複数の fixed とひとつの flexible(最大はデフォルトの無限大のまま)を使うと表示範囲を全て使いグリッドで埋めることができます。

複数の flexible を使うと表示範囲のサイズによりどのような表示になるか確認が必要です。

adaptive では最小から最大の範囲でグリッドのサイズが可変になります。
最小に近いサイズでできるだけたくさんレイアウトする挙動です。
可変なので余白は発生しません
後半のサンプルでビューサイズを固定した表示例も紹介します。


2-3 シンプルな表示例

LazyVGrid を使ったサンプルです。

二つの fixed を使った(1行に二つの子ビューを表示)例です。

import SwiftUI
import PlaygroundSupport

struct GridView: View {
   private var columns = [
       GridItem(.fixed(100)),
       GridItem(.fixed(80))
   ]
   
   var body: some View {
       ScrollView {
           LazyVGrid(columns: columns, alignment: .leading) {
               ForEach(0...20, id:\.self) {
                   childView($0)
               }
           }
           .background(Color.gray)
       }
       .padding()
       
   }
   
   func childView(_ index:Int) -> some View {
       Color.red
           .overlay(
               Text("\(index)")
                   .font(.headline)
           )
           .frame(height:80)
   }
}
// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   GridView()
       .frame(width: 400, height: 500) // XcodeのPlaygroundで必要
)

LazyVGrid(columns: columns, alignment: .leading) { で垂直方向のグリッドを指定し columns で二つの .fixed を指定しています。

Xcode で実行すると

画像11

横方向に二つの赤いビューを表示します。
alignment: .leadingで左よせに表示しています。
LazyVGridの範囲を明確にするために背景を Color.gray で塗りつぶしています。


2-4 fixed と flexible を使った例

二つの GridItem のうち一つを flexible にしたサンプルです。

import SwiftUI
import PlaygroundSupport

struct GridView: View {
   private var columns = [
       GridItem(.fixed(100)),   // 幅100の固定幅
       GridItem(.flexible())    // デフォルトの可変幅
   ]
   
   var body: some View {
       ScrollView {
           LazyVGrid(columns: columns, alignment: .leading) {
               ForEach(0...20, id: \.self) {
                   childView($0)
               }
           }
           .background(Color.gray)
       }
       .padding()
   }
   
   func childView(_ index:Int) -> some View {
       Color.red
           .overlay(
               Text("\(index)")
                   .font(.headline)
           )
           .frame(height:80)
   }
}
// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   GridView()
       .frame(width: 400, height: 500) // XcodeのPlaygroundで必要
)

Xcodeで実行すると

画像12

表示範囲を flexible で埋めています。
flexible 設定のビューが幅全体を埋めるので、1行のビュー数は固定で余白は発生しません。


2-5 adaptive を使った例

minimum のみ指定し、maximum はデフォルトのままなので無限大です。

import SwiftUI
import PlaygroundSupport

struct GridView: View {
	private var columns = [
		GridItem(.adaptive(minimum: 70))   // maximumはデフォルトのままなので指定しない
	]
	
	var body: some View {
		ScrollView {
			LazyVGrid(columns: columns, alignment: .leading) {
				ForEach(0...20, id: \.self) {
					childView($0)
				}
			}
			.background(Color.gray)
		}
		.padding()
	}
	
	func childView(_ index:Int) -> some View {
		Color.red
			.overlay(
				Text("\(index)")
					.font(.headline)
			)
			.frame(height:80)
	}
}
// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
	GridView()
		.frame(width: 400, height: 500)
)

Xcode で実行すると

画像13

幅いっぱいに同じ幅のビューを並べて表示します。


ここから先は

13,566字 / 12画像 / 1ファイル
この記事のみ ¥ 500

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