Swift

初めてSwiftUIベースのアプリ作ってみた!

swiftui-app-first

初めてSwiftUIベースのアプリ作ってみた!

久々に個人開発で勉強兼ねて何かコードを書いてみようかなと思い、SwiftUIベースの簡単なアプリを作ってみました。
色々学びもあったので、備忘録も兼ねて残します。
3日くらいで作ったアプリなので、不具合があったりする可能性もあるので、その辺りは今後時間があるときにアップデートする予定です。
なお今回作ったサンプルアプリは、地図上から該当の空室のホテルを検索することができるWorkHotelというアプリです。

学んだこと・試したこと

SearchBarの実装方法

UIKitの場合、UISearchBarというものがありましたが、SwiftUIにもSearchBarを表示する機能があります。
以下のように、searchableというmodifierを使用することで、表示できます(アプリでの実装箇所)。ただしNavigationView の中に記述する必要があります
なおフォーカス時にキャンセルボタンも自動で表示されます。

.searchable(text: $viewModel.searchText, placement: .navigationBarDrawer, prompt: Text("地名を検索"))

なおPlaceholderを表示したい場合は、promptというパラメーターを指定することで、表示できます。
カスタム性は低ですが、実装も簡単なので、デザインにこだわらなければ、これで十分だと思います。

レビュー用の星のView

今回レビュー情報を表示したかったので、以下のようなよく見るレビュー用の星を表示するViewを実装しました。

実装したコードは、StarReviewView.swiftに記載があります。
実装としては、例えば4.5のようなDoubleを渡して、4個の赤い星と0.5の幅だけ赤い星とその裏にグレーの星を配置するという形です。

タイトル+コンテンツ用Viewの共通コンポーネント

今回詳細画面とオプション設定画面で、タイトル+カスタムのコンポーネントという表示をすることが多かったので、共通のコンポーネントとして、SubTitleView.swiftを実装しました。
このコンポーネントは、タイトル用のStringとそのタイトルの下に表示用のViewを渡すようにできていて、Viewを渡す箇所には、viewbuilderを使用しています。

public struct SubTitleView<T: View>: View {

    private let title: String
    private let childView: T
    private let padding: CGFloat = 4

    public init( title: String, @ViewBuilder childView: () -> T) {
        self.title = title
        self.childView = childView()
    }

    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack {
                Text(title)
                    .font(.system(size: 18, weight: .semibold))
                    .foregroundColor(Color(.label))
                    .background(Color.clear)
                    .padding(.horizontal, padding)
                Spacer()
            }
            .frame(height: 40)
            .background(Color(UIColor.systemGray5))
            childView
                .background(Color(UIColor.systemBackground))
                .padding(padding)
        }
    }
}

非同期で画像を取得するAsyncImage

iOS14まではURLの画像を非同期で取得する場合は、自前で実装 or ライブラリを使用する必要がありましたが、iOS15~は AsyncImageという標準のViewを使用することができます。今回は試しにそのAsyncImageを使ってみました(実装箇所)
引数には、表示する画像のURL、画像表示時にfadeなどのアニメーションを適用したい場合は、transactionを設定します。
クロージャーの中では、AsyncImagePhaseという値が取得でき、以下のように、①画像取得済み、②エラー時、③ローディング中に表示するViewを個別に出し分けることができます。
ただし画像のCacheには対応していないため、ダウンロード済みの画像をCacheして、通信回数を減らしたい場合、カスタムで自作する or swiftui-cached-async-imageなどのライブラリを使用する必要があります。

AsyncImage(url: hotelInfo.hotelImageURL, transaction: Transaction(animation: .easeInOut(duration: 0.6))) { phase in
    if let image = phase.image {
        image
        .resizable()
        .aspectRatio(contentMode: .fill)
    } else if phase.error != nil {
        Text("No image")
    } else {
        ProgressView()
    }
}

カード型のカルーセルView

このサンプルアプリでは、ホテル情報をピンと一覧で表示していますが、その一覧を画面下にカルーセルの形で表示していて、横にスワイプすると、前後のカードを表示することができるものです。
実装はCarouselView.swiftに記載があります。

一覧>詳細への遷移方法

一般的にSwiftUIでPush型の画面遷移をしたい場合、以下のようにNavigationLinkを使用すると思います。

List {
    ForEach(items, id: \.self) { item in
        NavigationLink(
            destination: Text(item),
            label: {
                Text(item)
            }
        )
    }
}

しかし今回のアプリの場合、上記のような実装でカードのViewをNavigationLinkで囲うと、スワイプのジェスチャーとNavigationLinkのジェスチャーが混合してしまい、アプリが上手く動作しませんでした。
そのためこのアプリでは、上記のように直接カードのViewをNavigationLinkで囲うのではなく、カードをタップして、isPushNavigationActiveというフラグを切り替えて、trueになった時に、詳細画面を表示するようにしてます。(実装箇所)

NavigationLink(isActive: $viewModel.isPushNavigationActive) {
    workHotelDetailView
} label: {
    EmptyView()
}

また遷移先の詳細画面については、APIで非同期で取得した詳細の情報を渡す必要があるため、ViewBuilderで画面遷移時に生成するようにしています。
なおこのViewでは、遷移元はカードになるので、上記のNavigationLinkには、遷移元のViewはEmptyViewとしています。

@ViewBuilder
private var workHotelDetailView: some View {
    if !viewModel.hotelList.isEmpty, viewModel.hotelList.count > viewModel.selectedIndex {
        WorkHotelDetailView(hotelInfo: viewModel.hotelList[viewModel.selectedIndex])
            .navigationBarTitle("ホテル詳細", displayMode: .inline)
    } else  {
        EmptyView()
    }
}

ローディング画面

通信中のLoadingについては、overlayのmodifierを使用しています。
isLoadingのプロパティを監視して、trueの時にProgressViewを表示するようにしています。

.overlay {
    if viewModel.isLoading {
        ProgressView("Fetching data, please wait...")
            .progressViewStyle(CircularProgressViewStyle(tint: Color(WorkHotelCommon.themeColor)))
    }
}

SwiftConcurrency対応したAlamofireの通信処理

今回のサンプルアプリの通信処理は、SwiftConcurrencyを使用していますが、AlamofireもすでにSwiftConcurrencyに対応しているので、今回はそれを使用しています。
実装はシンプルで、以下のように書くだけでOKです。
RDecodableのstructです。

let response = await AF.request(requestURL, method: method).serializingDecodable(R.self).response

responseとして、DataResponse<Decodable, AFError>というものが返ってきます。
response.valueDecodableの値、response.errorでエラー、response.responseHTTPURLResponseが取得できます。
Decode作業もAlamofireで行うことができるの、記述量が少なく済みます。

おまけ: 楽天トラベル空室検索APIの使用方法

このアプリでは、データの取得のために、楽天トラベル空室検索APIを利用しています。
利用方法としては、楽天IDで登録するだけで使用することができるので、利用自体のハードルは低いのです。
しかし実装していて気づいたのが、レスポンスの各プロパティでどれがnullで返ってくるかわからないので、DecodableOptionalにしていないものがnullで返ってきてしまうと、Decodableでparseエラーになってしまいます。
それを防ぐためには、基本全てOptionalに設定するか、APIのレスポンスを利用しながら、適宜nullで返ってくるものをOptionalにしていくしかないと思います。

感想など!

今までのイメージだと、SwiftUIは簡単な画面やUI向けのものって感じでしたが、今回SwiftUIベースで実装して、結構複雑なUIもSwiftUIで作れると感じました。
昨日のWWDC22でもSwfitUIに関する様々なアップデートも発表され、今後より一層SwiftUIでアプリが書きやすく、そして使いやすくなると感じました。現状UIKitの力を借りないといけない場面もありますが、今後開発効率を考慮しても、SwiftUIを使用する場面が増えると思うので、SwiftUIのキャッチアップも強化していこうと思いました。

+2

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA