SwiftUIのViewBuilderについて調べてみる
SwiftUIのVStackやHStackでは、下のようにTextを2行並べて書くだけでTextを縦並びにできます。
今回はこの仕組みについて調べてみました。
struct ContentView: View { var body: some View { VStack { Text("AAA") Text("BBB") } } }
上記のコードですが、下のような記述と同等になります。
複数個並べられていたTextは、ViewBuilderのbuildBlockの引数として使われます。
struct ContentView: View { var body: some View { VStack { ViewBuilder.buildBlock( Text("AAA"), Text("BBB") ) } } }
ViewBuilderのbuildBlockは、以下のようなTupleViewを返すメソッドです。
渡されたViewを元にTupleViewを作って返します。
extension ViewBuilder { public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View }
まとめると、省略記法を使わずにVStackを使う場合は以下のような記述になります。
// 元コード // struct ContentView: View { // var body: some View { // VStack { // Text("AAA") // Text("BBB") // } // } // } // 省略せずに書かれたコード struct ContentView: View { var body: some View { VStack(content: { () -> TupleView<(Text, Text)> in return ViewBuilder.buildBlock( Text("AAA"), Text("BBB") ) }) } }
続けて、ViewBuilderを省略できた理由を見ていきます。
省略できたのは、ViewBuilderの宣言についている@_functionBuilderというAttributesが関係しています。
@_functionBuilder public struct ViewBuilder { }
@_functionBuilderを使ってStructを作ると以下の@MyStruct
のようにクロージャーに付けるAttributesが作られます。
func myFunc(@MyStruct closure: () -> Int) -> Int { return closure() } @_functionBuilder struct MyStruct { static func buildBlock(_ v1: Int) -> Int { return v1 } static func buildBlock(_ v1: Int, _ v2: Int) -> Int { return v1 + v2 } static func buildBlock(_ v1: Int, _ v2: Int, _ v3: Int) -> Int { return v1 + v2 + v3 } }
@MyStructを付けたクロージャーは、以下のような省略記法を使えます。
ViewBuilderもこの機能を使う事でViewBuilder.buildBlock
という呼び出しを省略していました。
myFunc { 1 2 } // 上は以下の省略形 myFunc { MyStruct.buildBlock(1, 2) } func myFunc(@MyStruct closure: () -> Int) -> Int { return closure() }
余談ですが@_functionBuilderはclassにも使うことができます。
@_functionBuilder class MyClass { }
以下のように値の数がbuildBlockの引数の数を超えるとエラーになります。
myFunc { 1 2 3 4 }
ViewBuilderは引数が最大10個なので、下のようにViewを11個渡すとエラーになります。
struct ContentView: View { var body: some View { VStack { Text("1") Text("2") Text("3") Text("4") Text("5") Text("6") Text("7") Text("8") Text("9") Text("10") Text("11") } } }
参考URL
SwiftUIの魔法を実現する仕組み (Custom Attributes, Function Builder) - Qiita
SwiftUIでローカル変数を定義する方法
SwiftUIだとローカル変数の定義でエラーになることが多いので整理してみました。
SwiftUIでのローカル変数定義方法
bodyの中の場合は下のようにreturnを付けることで定義できます。
struct ContentView: View { var body: some View { let a = 1 return Text("Hello, World!") } }
ZStackのように複数Viewを返す場合はreturnを付けた上で() -> TupleView<(Text, Text)>
のように戻り値の型を指定する必要があります。
struct ContentView: View { var body: some View { ZStack { () -> TupleView<(Text, Text)> in let a = 1 return ViewBuilder.buildBlock( Text("AAA"), Text("BBB") ) } } }
定義に関する調査
なぜ上記方法でうまくいくかの調査は下の通りです。
下のように変数を定義するとFunction declares an opaque return type, but has no return statements in its body from which to infer an underlying type
というエラーになります。
これは値をreturnしていないというエラーです。
struct ContentView: View { var body: some View { let a = 1 Text("Hello, World!") } }
以下のようにreturnを付けることでエラーは出なくなります。
struct ContentView: View { var body: some View { let a = 1 return Text("Hello, World!") } }
ローカル変数を定義するまで動いていたのはSwiftのreturn文省略の仕様が理由です。
Swiftはクロージャー内が1行だけの場合はreturnを省略できるので、変数定義をしなければreturnを省略することができます。
struct ContentView: View { var body: some View { Text("Hello, World!") } }
mapメソッドなどでもreturnが不要なのはこの仕様が理由になります。
[1, 2, 3].map { $0 * 2 }
メソッドも同じようにreturnを省略できます。
struct ContentView: View { func test() -> some View { Text("Hello, World!") } }
returnを付ける方法ですが、Zstackの中では使うことができません。
下のように書くとCannot convert return expression of type 'ZStack<_>' to return type 'some View'
というエラーになります。
これはZStack<_>をsome Viewに変換できないというエラーです。
struct ContentView: View { var body: some View { ZStack { let a = 1 return Text("Hello, World!") } } }
このエラーは下のようにZStackの戻り値の型を宣言することで解消できます。
struct ContentView: View { var body: some View { ZStack { () -> Text in let a = 1 return Text("AAA") } } }
複数Viewを返す場合は下のようにViewBuilderを使ってTupleViewを作る必要があります。
struct ContentView: View { var body: some View { ZStack { () -> TupleView<(Text, Text)> in let a = 1 return ViewBuilder.buildBlock( Text("AAA"), Text("BBB") ) } } }
SwiftUIのEnvironment/EnvironmentObjectとはなにか
SwiftUIのEnvironment / EnvironmentObjectという記法について調べてみました。
EnvironmentObjectとは
EnvironmentObjectとはObservableObjectを外部から渡したりできるようになる記法です。
宣言は下のように行います。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { Text("Hello") } } class MyViewModel: ObservableObject { }
プロパティーの型はObservableObjectプロトコルに準拠している必要があります。
また、通常のプロパティーと違って初期値をセットする必要はありません。
@EnvironmentObject var testValue: MyViewModel
EnvironmentObjectの値は、下のようにenvironmentObjectメソッドで外部から注入することができます。
let contentView = ContentView().environmentObject(MyViewModel())
通常のプロパティー同様、値を以下のように取得することができます。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { print(testValue) return Text("Hello") } }
MyViewModelのプロパティーを2つ宣言すると、両方に同じ値がセットされます。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel @EnvironmentObject var testValue2: MyViewModel var body: some View { print(testValue === testValue2) // → true return Text("Hello") } }
environmentObjectでセットした値は、子ビューにも自動でセットされます。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { ChildView() } } struct ChildView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { print(testValue) // 値が入っている return Text("Hello, World!") } }
Environmentとは
EnvironmentもEnvironmentObjectと似て、外部から値をセットできるようになるクラスです。
下のようにenvironmentメソッドを使って値をセットできます。
let contentView = ContentView().environment(\.font, Font.system(size: 100))
ここでセットできるのは、fontなどEnvironmentValuesのプロパティーとして宣言されているものだけです。
詳細は以下URLに参考にしてください。
https://developer.apple.com/documentation/swiftui/environmentvalues
フォントをセットした場合、以下のようにTextのデフォルトフォントサイズが変更されます。
セットした値にアクセスするためには、以下のようにEnvironmentを使ってプロパティーを宣言する必要があります。
struct ContentView: View { @Environment(\.font) var myFont: Font? var body: some View { print(myFont) return Text("Hello") } }
参考URL
SwiftUIの機能 @State, @ObservedObject, @EnvironmentObjectの違いとは| 開発者ブログ | 株式会社アイソルート
Understanding Property Wrappers in SwiftUI | Majid’s blog about Swift development
SwiftUIのsomeキーワードについて調べてみる
SwiftUIでViewを作る時、以下のようにsomeというキーワードが出てきます。
今回はこのキーワードについて調べてみました。
struct ContentView: View { var body: some View { Text("Hello, World!") } }
調査にあたって以下の記事を参考にさせて頂きました。
Swift 5.1 に導入される Opaque Result Type とは何か - Qiita
someキーワードですが、実行時のパフォーマンス向上の為のキーワードになります。
具体的には「戻り値の型をプロトコルとして宣言するとメモリ使用量が増える」という悩みを解消してくれるものになります。
仮に下のようなクラスとプロトコルがあるとします。
protocol MyProtocol { } class MyClass: MyProtocol { }
その時、下の2つのメソッドを比べるとMyProtocolを戻り値の型にしたほうがメモリ使用量が大きくなってしまいます。
// メモリ使用量が大きい func myProtocol() -> MyProtocol { return MyClass() } // メモリ使用量が小さい func myProtocol() -> MyClass { return MyClass() }
それを解決するのがsomeキーワードです。
someを付ける事で、メソッドの実装によって戻り値の型が最適なものになります。
// メモリ使用量が大きい func myProtocol() -> MyProtocol { return MyClass() } // メモリ使用量が小さい func myProtocol() -> some MyProtocol { return MyClass() }
実際に比較してみた結果は下のとおりです。
1,000,000個のインスタンスを作って、メモリ使用量を比較してみました。
結果を見るとsome MyProtocol
はMyClass
とほぼパフォーマンスを出していることがわかります。
戻り値の型 | 1回目の測定結果 | 2回目の測定結果 | 3回目の測定結果 | 平均 |
---|---|---|---|---|
MyClass | 28.9mb | 27.2mb | 31.1mb | 29.1mb |
MyProtocol | 48.7mb | 58.3mb | 61.4mb | 56.2mb |
some MyProtocol | 28mb | 32.4mb | 31.6mb | 30.7mb |
100万回個のインスタンス生成の実装は以下の通りです。
let results = (0...1000000).map { _ in myProtocol() }
メモリ使用量はXcodeのMemory reportのHighの値を使用しました。
以下のように実行速度についても調べたのですが、こちらは大きな差はありませんでした。
let d = Date() let results = (0...1000000).map { _ in myProtocol() } print(Date().timeIntervalSince1970 - d.timeIntervalSince1970)
someキーワードはクラスにも使えるのですが、当然ながらsomeがあってもなくてもメモリ使用量に差は見えませんでした。
func myProtocol() -> some MyClass { return MyClass() }
余談ですがSwiftUIのViewからsomeを外すとProtocol 'View' can only be used as a generic constraint because it has Self or associated type requirements
というエラーになります。
これはViewが、associatedtypeを使っているためです。
struct ContentView: View { var body: View { Text("Hello, World!") } }
someは引数には使うことができません。
引数はジェネリクスで対処する必要があります。
func myProtocol(myClass: some MyProtocol) { } // → x func myProtocol<A: MyProtocol>(myClass: A) { } // → o
Today Extensionでウィジェット作成
Today Extensionを使ったウィジェット作成を試してみました。
プロジェクトを作成したらメニューからTargetの作成を行います。
Today Extensionを選択します。
作成するとToday Extension関連ファイルが作られます。
Today Extensionを起動するとHello worldが表示されます。
Storyboardでテキストを変更する事で文字を変える事ができます。
ラベルはUIViewControllerと紐付ける事で動的な変更もできます。
class TodayViewController: UIViewController, NCWidgetProviding { @IBOutlet weak var myLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() myLabel.text = "Hello!" } }
ラベルだけでなくボタンも配置する事ができます。
widgetLargestAvailableDisplayModeをセットする事でウィジェットのサイズを大きくできます。
サイズはwidgetActiveDisplayModeDidChangeでセットする事が可能です。
class TodayViewController: UIViewController, NCWidgetProviding { override func viewDidLoad() { super.viewDidLoad() extensionContext?.widgetLargestAvailableDisplayMode = .expanded } func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) { switch activeDisplayMode { case .compact: preferredContentSize = maxSize case .expanded: preferredContentSize = CGSize(width: preferredContentSize.width, height: 400) } } }
UserDefaultsなどのデータ共有にはApp Groupsが必要になります。
まずはXcode上でApp Groupsを作成します。
この時はアプリのターゲットだけでなくExtension側にも追加が必要です。
あとは作成したグループを使ってデータ作成すればデータ共有できるようになっています。
UserDefaults(suiteName: "group.mygroup")?.set(100, forKey: "Key") print(UserDefaults(suiteName: "group.mygroup")?.integer(forKey: "Key"))
Realmなどのデータベースのデータ共有もApp Groupsを使います。
var config = Realm.Configuration.defaultConfiguration let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.mygroup") config.fileURL = url?.appendingPathComponent("default.realm") Realm.Configuration.defaultConfiguration = config
データだけでなくクラスも共有できないので、Embedded Frameworkの利用やCompile Sources
への追加などが必要です。
CocoaPodsを使う場合、Extension用の処理も必要があるので注意が必要です。
target 'MyApp' do use_frameworks! pod 'RealmSwift' end target 'MyExtension' do use_frameworks! pod 'RealmSwift' end
SwiftUIを触ってみる
SwiftUIで簡単なUIを作ってみました。
まずはXcode11で新規プロジェクトを作成します。
テンプレートは「Single View App」を選びました。
プロジェクト構成は以下のようになっています。
Xcode11からはSceneDelegate.swiftというファイルが追加されています。 これはiPadで画面に複数アプリ表示する際に使うクラスで、今までのAppDelegateの役割の一部を担っています。 今回はSwiftUIを中心に見たいので深堀りしないことにします。
まずはアプリを立ち上げてみます。 アプリを起動すると下のようにHello, World!と表示されます。
その下にラベルを1つ追加してみたいと思います。 ContentViewクラスを以下のように修正します。 2つ縦に並べるのはVStackというクラスを使っています。
struct ContentView: View { var body: some View { VStack { Text("Hello, World!") Text("Goodnight, World!") } } }
アプリを起動するとラベルが2つ表示されています。
次にラベル間を少し空けてみます。 VStackの初期化時にspacingを渡します。
struct ContentView: View { var body: some View { VStack(spacing: 200) { Text("Hello, World!") Text("Goodnight, World!") } } }
これで間隔を200pxにする事ができました。
ラベルと色やフォントも変更してみます。
struct ContentView: View { var body: some View { Text("Hello, World!").font(Font.system(size: 50)).foregroundColor(Color(red: 1, green: 1, blue: 0)) } }
少し見づらいですが黄色の大きめの文字に変わりました。
次はボタンの追加とタップ時のアクション追加を行います。
struct ContentView: View { var body: some View { Button("ボタン") { print(10) } } }
ボタンをタップするとデバッグエリアに10が表示されます。
次はボタンタップ時にアラートも表示してみます。
struct ContentView: View { @State var isPresented = false var body: some View { Button("ボタン") { self.isPresented = true }.alert(isPresented: $isPresented) { Alert(title: Text("Title"), message: Text("Message")) } } }
アラートを表示する事ができました。
この.alert(isPresented:)
はButtonに付いている必要はなく以下のようにTextに付いていても動きます。
struct ContentView: View { @State var isPresented = false var body: some View { VStack { Text("Hello, World!").alert(isPresented: $isPresented) { Alert(title: Text("Title"), message: Text("Message")) } Button("ボタン") { self.isPresented = true } } } }
次はボタンを押した時にテキストの内容を変える処理をします。
struct ContentView: View { @State var text = "Hello, World" var body: some View { VStack { Text(text) Button("ボタン") { self.text = "Goodnight, World" } } } }
ボタンを押すとテキストを変更する事ができました。
以下のようにViewModelを別途作る事もできます。
class MyViewModel : ObservableObject, Identifiable { @Published var text: String = "Hello, World" } struct ContentView: View { @ObservedObject var viewModel = MyViewModel() var body: some View { VStack { Text(viewModel.text) Button("ボタン") { self.viewModel.text = "Goodnight, World" } } } }
データをTextFieldと紐付ける事もできます。
TextFieldの引数はBinding<String>
なので$viewModel.text
とドルを付けてBindingオブジェクトに変えて上げる必要があります。
class MyViewModel : ObservableObject, Identifiable { @Published var text: String = "Hello, World" } struct ContentView: View { @ObservedObject var viewModel = MyViewModel() var body: some View { VStack { Text(viewModel.text) TextField("Placeholder", text: $viewModel.text) } } }
画面遷移はNavigationViewを使うと実現できます。
struct ContentView: View { var body: some View { NavigationView { NavigationLink(destination: DestinationView()) { Text("ボタン") } } } } struct DestinationView: View { var body: some View { Text("遷移先") } }
Googleのデータポータル(旧DataStudio)でアプリの新規ユーザーをグラフ化
先日の記事でFirebaseのデータをBigQueryに追加しました。 今度はBigQueryのデータをデータポータルというツールを使ってグラフ化してみようと思います。
データポータルとは
データポータルとはGoogleの提供しているデータをビジュアライズ化するツールです。 今回はBigQueryのデータをグラフ化するのですが、BigQuery以外にGoogleアナリティクス・Googleスプレットシート・MySQLのデータなど色々なデータを取り込んで表示する事ができます。
取り込んだデータは下のように様々な形で表示する事ができます。
BigQueryで取り込んだデータを表示する
データポータルのページに移動してから、「空のレポート」を選んでレポート作成します。
レポートを作ったら右下の「新しいデータソースを作成」ボタンからデータソースを作ります。
ボタンを押すとデータ取得元を選ぶ画面に移動します。 今回はBigQueryを選択します。
選択と下のようにデータセット選択ページに飛ぶのでプロジェクトのAnalyticsのデータを選択します。 events_intraday_YYYYMMDDは直近のデータのみなので、今回はevents_YYYYMMDDを選択しました。
フィールドを選ぶ画面が出るんですがここは何も変更せず保存します。
それが終わると画面にイベント数の入ったテーブルが表示されます。 今回は新規ユーザー数を表示したいのでこれは削除します。
削除してから「グラフを追加」で時系列グラフを追加します。
デフォルトだと全イベントの数が入っているので、新規ユーザー数のみに絞ります。 右側の「フィルタを追加」ボタンから、新規登録数を表示するためのフィルターを作成します。
フィルターはEvent Nameがfirst_open(初めてアプリ立ち上げ)のイベントで絞り込みます。
以上で設定は完了です。 2日分しかデータ入れてないのですが、無事に新規ユーザー数を表示する事ができました。