初めて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です。
R
はDecodable
のstructです。
let response = await AF.request(requestURL, method: method).serializingDecodable(R.self).response
responseとして、DataResponse<Decodable, AFError>
というものが返ってきます。
response.value
でDecodable
の値、response.error
でエラー、response.response
でHTTPURLResponse
が取得できます。
Decode
作業もAlamofire
で行うことができるの、記述量が少なく済みます。
おまけ: 楽天トラベル空室検索APIの使用方法
このアプリでは、データの取得のために、楽天トラベル空室検索APIを利用しています。
利用方法としては、楽天IDで登録するだけで使用することができるので、利用自体のハードルは低いのです。
しかし実装していて気づいたのが、レスポンスの各プロパティでどれがnull
で返ってくるかわからないので、Decodable
でOptional
にしていないものがnull
で返ってきてしまうと、Decodable
でparseエラーになってしまいます。
それを防ぐためには、基本全てOptional
に設定するか、APIのレスポンスを利用しながら、適宜null
で返ってくるものをOptional
にしていくしかないと思います。
感想など!
今までのイメージだと、SwiftUIは簡単な画面やUI向けのものって感じでしたが、今回SwiftUIベースで実装して、結構複雑なUIもSwiftUIで作れると感じました。
昨日のWWDC22でもSwfitUIに関する様々なアップデートも発表され、今後より一層SwiftUIでアプリが書きやすく、そして使いやすくなると感じました。現状UIKitの力を借りないといけない場面もありますが、今後開発効率を考慮しても、SwiftUIを使用する場面が増えると思うので、SwiftUIのキャッチアップも強化していこうと思いました。