Open8

FlutterエンジニアのSwiftUI入門

tatsubeetatsubee

FlutterエンジニアのSwiftUI入門

モチベーション

Flutterしか基本的にやってこなかった人間がSwiftUIでのiOS開発の勉強を始めたので

  • 学んだことの備忘録
  • 同じ境遇の方々への共有

を目的としてスクラップに残そうと思いますー

このスクラップの対象読者

上記に書いた通りFlutterをやってきたがiOSネイティブを学んだことのない人、
つまりFlutterをある程度書ける人がSwiftUIを学ぶ際に助けになるかと思います。

SwiftUI学習方法

お願い

筆者はSwift初心者です。
間違っている内容も記載している可能性があるので、誤りを見つけた際には報告してくださると大変助かります!

目次

Swift Language Specifications
Chapter 1 SwiftUI Essentials
SwiftでのUI作成の基礎を学べます。
Creating and Combining Views
Building Lists and Navigation
Handing User Inputs

tatsubeetatsubee

Swift Language Specifications

SwiftUIでのアプリ開発の記録の前に、この時点で把握できたSwiftの言語使用についてここに記載します。

変数宣言

Dart
var text1 = 'text'; // 再代入可能な動的変数
final text2 = 'text'; // 再代入不可能な動的変数
const text3 = 'text'; // コンパイル時に値が代入される定数

String text4 = 'text' // 静的変数
String? text5 = null // nullableな静的変数
Swift
var text1 = "text" // 再代入可能な動的変数(Dartのtext1に対応)
let text2 = "text" // 再代入不可能な動的変数(Dartのtext2に対応)

var text3: String = "text" // 静的変数(Dartのtext4に対応)
var text3: String? = nil // Optional型変数(Dartのtext5に対応)

まず言語仕様として

  • Dartに必要なセミコロン「;」がSwiftでは不要
  • String型を表現する際にDartでは通例的にシングルクォーテーション「'」で囲っているが、Swiftではダブルクォーテーション「"」で囲う必要があり、「'」では文字列と認識されない。

変数宣言におけるDartとSwiftの違いとして

  • 静的型付けを行う際の表現方法が異なる
    • Dart: String text4 = 'text'
    • Swift: var text3: String = "text"

SwiftはTypeScriptっぽいですね。

また、Swiftには上記のDartと共通する書き方に加えて、null safetyな変数の判別のためにguardifなる機能が存在します。
guardは宣言した変数にnilが代入された場合、エラーを投げる等で処理をその時点で終了させたい場合に使います(多分)。
ifは宣言した変数にnilが代入されたかどうかで異なる処理を行いたい場合に使います。
guard

Swift
guard let text = hoge else {
    // 変数宣言した「text」にnilが代入された場合(つまりhoge == nilだった場合)の処理
    // これ以上処理を進めないようにこれで退出させます
    return 
}
// 変数宣言した「text」がnilではない値が代入された場合の処理
// その後の処理を続けます

Dartにおける次の処理と多分同等です。いわゆる早期リターンですね。

Dart
final String? text = hoge;
if (text == null) {
    return;
}

if

Swift
guard let text = hoge {
    // 変数宣言した「text」がnilではない値が代入された場合の処理
} else {
    // 変数宣言した「text」にnilが代入された場合(つまりhoge == nilだった場合)の処理
}

こちらも見慣れはしませんがguardよりは直感的にわかりますね。

関数の定義

引数に2つの文字列を与え、その文字列をつなげて返してくれる関数を作ってみます

Dart
// 名前付き引数
String getJoinedText1({required String text1, required String text2}) {
    return text1 + text2;
}
// 名前なし引数
String getJoinedText2(String text1, String text2) {
    return text1 + text2;
}

final joinedText1 = getJoinedText1(text1: 'text1', text2: 'text2');
final joinedText2 = getJoinedText2('text1', 'text2');
Swift
// 名前付き引数
func getJoinedText1(text1: String, text2: String) -> String {
    return text1 + text2
}
// 名前なし引数
func getJoinedText1(_ text1: String, _ text2: String) -> String {
    return text1 + text2
}

let joinedText1 = getJoinedText1(text1: "text1", text2: "text2");
let joinedText2 = getJoinedText2("text1", "text2");

上記の通り、Swiftでは名前なし引数の方をアンダースコア「 _ 」で明示するようです。
またアロー関数的な書き方は今まで 「=>」 で表現していたので 「->」 に慣れるまで少しかかりそうです。

アンダースコア「 _ 」の使い方

関数で出てきたついでにSwiftでの「 _ 」について説明します。
Dartでの「 _ 」はprivateであることを表現するprefixでしたが、
Swiftでの「 _ 」は「使わないこと」を表現するための表現です。以下具体例

Swift
// (1)
func getJoinedText(_ text1: String, _ text2: String) -> String {
    return text1 + text2
}

// (2)
_ = getJoinedText("text1", "text2");
  • (1): 名前なし引数を表現
  • (2): 返り値を使用しないことを明記

(1)は関数で説明した通り、関数の引数に何を渡すのか明らかなので引数の名前が必要ない時には
func functionName(_ argumentName: argumentType) -> returnType {}
で表現します。
(2)について、ほぼDartしか扱っていない僕的には最初意味わからなかったのですが、関数を実行した際にその返り値を使用しない場合にはその返り値「 _ 」に代入するようです。
しかも代入先の「 _ 」はvarやletでの宣言は必要なく、また何度でも使用可能だと。へ〜。
FlutterでのshowDialogでAlertDialog等をとりあえず表示したく、popでの返り値を使わない場合を表現してみます。

Dart
// Flutterの普通の書き方
ElevatedButton(
    onPressed: () async {
        await showDialog<T>(...);
    },
    child: const Text('showDialog'),
),

// Swift的な書き方
// pop時にT型の値が返ってくるのでそれを「 _ 」という変数にぶち込む
ElevatedButton(
    onPressed: () async {
       dynamic _; // Swiftでは宣言不要
        _ = await showDialog<T>(...);
    },
    child: const Text('showDialog'),
),

つまり「 _ 」はゴミ箱です。
このアンダースコアの表現は必須で、「 _ 」に関数の返り値を代入しないと「Result of call to '関数名' is unused」というwarningを吐くようです。

もう一つ、関数の引数に「 _ 」を入れる場合もあるようですがまだ理解できていないので理解できしだい追記します。ごめんなさい。

tatsubeetatsubee

Creating and Combining Views

この章ではFlutterでいうStatelessWidgetでのUI作成の基礎を学べます。

Dart
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
    const HomePage({super.key});
    @override
    Widget build(BuildContext context) {
        return Padding(
            padding: EdgeInsets.all(10),
            child: Text(
                'Hello World!', 
                style: Theme.of(context).textTheme.head1),
        );
    }
}
Swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .font(.title)
            .padding()
    }
}

SwiftUIでは

  • var body: some View {}がFlutterでのWidget build(BuildContext context) {}に相当(someって何だろう?)
  • またpaddingやtextStyleのようなプロパティはTextの中ではなく外に連結する形で記載。

ネストが深くならないように配慮されてるんかなぁ?

またFlutterでのStack,Column, RowZStack, VStack,HStackと方向+Stackで命名されており、以下のように表現できる。

!!注意!! 以降「import SwiftUI」は省略しています。不必要なわけではありません。

Swift
struct ContentView: View {
    var body: some View {
        VStack {
            Text("text1")
            Text("text2")
        }
        .font(.subheadline)
    }
}

Stackに連結されたプロパティはStack内の全ての要素に影響するのでその点簡潔でいいね。

気をつけたいのが、全てにおいて連結のように表現するわけではなくFlutterと同じように引数で表現されるものもある。

Swift
struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("text1")
            Text("text2")
        }
        .font(.subheadline)
    }
}

連結か引数かどっちかに統一してほしくない?

さらにこの章の中ではswiftファイルを分けてViewを記載し、1つのswiftファイルのViewにまとめる方法も学べます。
そしてこの章の完成形が以下のコード

Swift
import SwiftUI // あえてimport文を省略せず明記しています

struct ContentView: View {
    var body: some View {
        VStack {
            MapView() // MapView.swiftから読み取り
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)

            CircleImage() // CircleImage.swiftから読み取り
                .offset(y: -130)
                .padding(.bottom, -130)

            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title)

                HStack {
                    Text("Joshua Tree National Park")
                    Spacer()
                    Text("California")
                }
                .font(.subheadline)
                .foregroundColor(.secondary)

                Divider()

                Text("About Turtle Rock")
                    .font(.title2)
                Text("Descriptive text goes here.")
            }
            .padding()

            Spacer()
        }
    }
}

気づく人は気づくと思いますが、他ファイルの値を使用する際でもimport文を記載する必要がありません。
Flutter以上にグローバルな値が悪さしそうで怖いですね。

tatsubeetatsubee

List

FlutterにおけるListView(.builder)の構築を学べます
結論として次のコードになるのですが

Swift
// Model
struct Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String
    
    private var imageName: String
    var image: Image {
        Image(imageName)
    }
    
    private var coordinates: Coordinates
    var locationCoordinates: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude
        )
    }
    
    struct Coordinates: Hashable, Codable {
        var latitude: Double
        var longitude: Double
    }
}
Swift
// Landmarkを引数に受け取るListTile的なViewを定義
struct LandmarkRow: View {
    var landmark: Landmark
    
    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()
        }
    }
}
Swift
// Landmarkの配列を生成(生成方法は省略)
var landmarks: [Landmark] = ...

// ListViewを生成
struct LandmarkList: View {
    var body: some View {
        List {
            LandmarkRow(landmark: landmarks[0])
            LandmarkRow(landmark: landmarks[1])
            LandmarkRow(landmark: landmarks[2])
        }
    }
}

// ListView.builderを生成
struct LandmarkList: View {
    var body: some View {
        List(landmarks) { 
            landmark in LandmarkRow(landmark: landmark)
        }
    }
}

厄介なのがListView.builderを生成するとき
上記の場合だと配列になっているModelのLandmarkIdentifiableが宣言されているためListView.builderをfor inの要領で簡潔に表現できているが、Identifiableが宣言されていない場合、次のようになる

Swift

struct LandmarkList: View {
    var body: some View {
        List(landmarks, id: \.id) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}

id: \.idってなんだ!

これはViewが要素を再生成するかどうかを判断するために必要な識別子(名前の通りだね)で、Flutterでいうkeyのようなもの?
Identifiableを宣言することでそのModelには予めidを持たせることができるらしい。
Identifiableを宣言しない場合はModelが持たないidをListの引数に追加で渡す必要があり、それがid: \.id
\.が要素そのものを示すpathで、\.idはつまりlandmark.idということになる。
要素のModelインスタンス自体をidとすることも可能らしくその場合は\.self

tatsubeetatsubee

SwiftUIでNavigationを実装する際には次のようなコードになる

Swift

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                NavigationLink {
                    LandmarkDetail(landmark: landmark) // 遷移先
                } label: {
                    LandmarkRow(landmark: landmark) // 遷移元の要素
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}

遷移先をNavigationLinkで、遷移元をlabel:で囲むことでNavigationのボタンを実装できる。(これだけでは機能しない)
またそれらをまとめてNavigationViewで囲うことでNavigationとして機能する。
Flutterで表現すると次のコードかな

Dart

class LandmarkList extends StatelessWidget {
  const ({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: landmarks.length,
        itemBuilder: (context, index) {
            final landmark = landmarks[index];

            return GestureDetecter(
                onTap: () => Navigatior.of(context).push<void>(
                    MaterialPageRoute(
                        builder: (context) => LandmarkDetail(landmark: landmark)
                    ),
                ),
                child: LandmarkRow(landmark: landmark);
            );
        }
    );
  }
}