しめ鯖日記

swift, iPhoneアプリ開発, ruby on rails等のTipsや入門記事書いてます

SwiftUIのViewBuilderについて調べてみる

SwiftUIのVStackやHStackでは、下のようにTextを2行並べて書くだけでTextを縦並びにできます。
今回はこの仕組みについて調べてみました。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("AAA")
            Text("BBB")
        }
    }
}

f:id:llcc:20200322142651p:plain

上記のコードですが、下のような記述と同等になります。
複数個並べられていた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のデフォルトフォントサイズが変更されます。

f:id:llcc:20200308204035p:plain

セットした値にアクセスするためには、以下のように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 MyProtocolMyClassとほぼパフォーマンスを出していることがわかります。

戻り値の型 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の値を使用しました。

f:id:llcc:20200127154802p:plain

以下のように実行速度についても調べたのですが、こちらは大きな差はありませんでした。

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の作成を行います。

f:id:llcc:20200113191348p:plain

Today Extensionを選択します。

f:id:llcc:20200113191455p:plain

作成するとToday Extension関連ファイルが作られます。

f:id:llcc:20200113191618p:plain

Today Extensionを起動するとHello worldが表示されます。

f:id:llcc:20200113191726p:plain

Storyboardでテキストを変更する事で文字を変える事ができます。

f:id:llcc:20200113192736p:plain

ラベルはUIViewControllerと紐付ける事で動的な変更もできます。

class TodayViewController: UIViewController, NCWidgetProviding {
    @IBOutlet weak var myLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        myLabel.text = "Hello!"
    }
}

ラベルだけでなくボタンも配置する事ができます。

f:id:llcc:20200113205708p:plain

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側にも追加が必要です。

f:id:llcc:20200113193701p:plain

あとは作成したグループを使ってデータ作成すればデータ共有できるようになっています。

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への追加などが必要です。

f:id:llcc:20200113203354p:plain

CocoaPodsを使う場合、Extension用の処理も必要があるので注意が必要です。

target 'MyApp' do
  use_frameworks!

  pod 'RealmSwift'
end

target 'MyExtension' do
  use_frameworks!

  pod 'RealmSwift'
end

SwiftUIを触ってみる

SwiftUIで簡単なUIを作ってみました。

まずはXcode11で新規プロジェクトを作成します。

f:id:llcc:20191201174101p:plain

テンプレートは「Single View App」を選びました。

f:id:llcc:20191201174134p:plain

プロジェクト構成は以下のようになっています。

f:id:llcc:20191201175809p:plain

Xcode11からはSceneDelegate.swiftというファイルが追加されています。 これはiPadで画面に複数アプリ表示する際に使うクラスで、今までのAppDelegateの役割の一部を担っています。 今回はSwiftUIを中心に見たいので深堀りしないことにします。

まずはアプリを立ち上げてみます。 アプリを起動すると下のようにHello, World!と表示されます。

f:id:llcc:20191201180656p:plain

その下にラベルを1つ追加してみたいと思います。 ContentViewクラスを以下のように修正します。 2つ縦に並べるのはVStackというクラスを使っています。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, World!")
            Text("Goodnight, World!")
        }
    }
}

アプリを起動するとラベルが2つ表示されています。

f:id:llcc:20191201180957p:plain

次にラベル間を少し空けてみます。 VStackの初期化時にspacingを渡します。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 200) {
            Text("Hello, World!")
            Text("Goodnight, World!")
        }
    }
}

これで間隔を200pxにする事ができました。

f:id:llcc:20191201181253p:plain

ラベルと色やフォントも変更してみます。

struct ContentView: View {
    var body: some View {
        Text("Hello, World!").font(Font.system(size: 50)).foregroundColor(Color(red: 1, green: 1, blue: 0))
    }
}

少し見づらいですが黄色の大きめの文字に変わりました。

f:id:llcc:20191201182626p:plain

次はボタンの追加とタップ時のアクション追加を行います。

struct ContentView: View {
    var body: some View {
        Button("ボタン") {
            print(10)
        }
    }
}

ボタンをタップするとデバッグエリアに10が表示されます。

f:id:llcc:20191201181647p:plain

次はボタンタップ時にアラートも表示してみます。

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

アラートを表示する事ができました。

f:id:llcc:20191201183827p:plain

この.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"
            }
        }
    }
}

ボタンを押すとテキストを変更する事ができました。

f:id:llcc:20191201184821p:plain

以下のように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)
        }
    }
}

f:id:llcc:20191201190104p:plain

画面遷移は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のデータをデータポータルというツールを使ってグラフ化してみようと思います。

www.cl9.info

データポータルとは

データポータルとはGoogleの提供しているデータをビジュアライズ化するツールです。 今回はBigQueryのデータをグラフ化するのですが、BigQuery以外にGoogleアナリティクス・Googleスプレットシート・MySQLのデータなど色々なデータを取り込んで表示する事ができます。

datastudio.google.com

取り込んだデータは下のように様々な形で表示する事ができます。

f:id:llcc:20191201114725p:plain

BigQueryで取り込んだデータを表示する

データポータルのページに移動してから、「空のレポート」を選んでレポート作成します。

f:id:llcc:20191201115052p:plain

レポートを作ったら右下の「新しいデータソースを作成」ボタンからデータソースを作ります。

f:id:llcc:20191201115158p:plain

ボタンを押すとデータ取得元を選ぶ画面に移動します。 今回はBigQueryを選択します。

f:id:llcc:20191201115248p:plain

選択と下のようにデータセット選択ページに飛ぶのでプロジェクトのAnalyticsのデータを選択します。 events_intraday_YYYYMMDDは直近のデータのみなので、今回はevents_YYYYMMDDを選択しました。

f:id:llcc:20191201115907p:plain

フィールドを選ぶ画面が出るんですがここは何も変更せず保存します。

f:id:llcc:20191201171001p:plain

それが終わると画面にイベント数の入ったテーブルが表示されます。 今回は新規ユーザー数を表示したいのでこれは削除します。

f:id:llcc:20191201171031p:plain

削除してから「グラフを追加」で時系列グラフを追加します。

f:id:llcc:20191201171127p:plain

デフォルトだと全イベントの数が入っているので、新規ユーザー数のみに絞ります。 右側の「フィルタを追加」ボタンから、新規登録数を表示するためのフィルターを作成します。

f:id:llcc:20191201171243p:plain

フィルターはEvent Nameがfirst_open(初めてアプリ立ち上げ)のイベントで絞り込みます。

f:id:llcc:20191201171318p:plain

以上で設定は完了です。 2日分しかデータ入れてないのですが、無事に新規ユーザー数を表示する事ができました。

f:id:llcc:20191201171554p:plain