前回のチュートリアル記事ではmicroCMSのセットアップとSwiftUIを使った記事の一覧表示を行いました。
今回はこの続きとして詳細画面の開発を進めていきます。
全5回の内容
本記事は全5回から成るSwiftUI + microCMSのチュートリアル記事です。
- microCMSのセットアップとSwiftUIを使った記事一覧表示
- 繰り返しフィールドを活用した詳細ページの開発 ← 本記事です
- offset/limitパラメータによる一覧表示のページング(作成中...)
- 検索機能の導入(作成中...)
- コンテンツ参照を使ったカテゴリ機能の追加(作成中...)
詳細画面への遷移
それでは作業を進めましょう。
まずおさらいですが、前回は以下のような一覧画面の表示まで実装しました。
今回はまず、こちらの一覧画面の行をタップすると詳細画面に遷移するようにしていきます。
最初に遷移先のViewとなるArticleDetail
を作成します。ArticleDetail
では生成時にコンテンツのidを受け取る実装となっており、現時点では受け取ったコンテンツのidを画面に表示するだけのものとします。
//ArticleDetail.swift
import SwiftUI
struct ArticleDetail: View {
var contentId: String
var body: some View {
Text(contentId)
}
}
struct ArticleDetail_Previews: PreviewProvider {
static var previews: some View {
ArticleDetail(contentId: "my-first-content")
}
}
作成できたらArticleList
側に遷移用の実装を追加します。
以下のようにNavigationView
、NavigationLink
を追加してください。
遷移時にはパラメータとしてコンテンツのidを渡すことも忘れないようご注意ください。
//ArticleList.swift より抜粋
var body: some View {
NavigationView {
List(microCMS.articles) { article in
NavigationLink(destination: ArticleDetail(contentId: article.id)) {
ArticleListRow(article: article)
}
}
}.onAppear {
microCMS.load()
}
}
ここまでの実装で一覧画面から詳細画面への遷移を実装できました。
次に詳細画面の実装を進めていきます。
詳細画面を実装する
さきほど一覧画面(ArticleList
)から詳細画面(ArticleDetail
)にはコンテンツのidのみをパラメータとして渡すよう実装しました。
詳細画面ではまず、このコンテンツのidを元に改めてmicroCMSより記事の詳細データを取得します。
一覧画面側で取得した内容を全て渡せばここでの問い合わせは必要ない場合もありますが、メディアアプリによくある要件としてCustom URL Scheme(ディープリンク)を用いて詳細画面に直接遷移するケースがあります。
こういった要件が出てきた場合にもうまく画面表示ができるような想定をして、今回は以下のように一覧画面・詳細画面のそれぞれでmicroCMSよりデータ取得を行います。
詳細情報の取得を行う
それでは詳細情報の取得を行います。まず、第1回の記事でModel
ファイルに記載したMicroCMSRequester
で詳細情報も取得できるように機能を拡張します。
詳細情報を取得するGET APIについて詳しくはドキュメントをご参照ください。
//Model.swift
class MicroCMSRequester: ObservableObject {
@Published var articles = [Article]()
//追加ここから============
@Published var article: Article? = nil
//ここまで============
private let iso8601DateFormatter = ISO8601DateFormatter()
init() {
iso8601DateFormatter.formatOptions.insert(.withFractionalSeconds)
}
func load() {
var request = URLRequest(url: URL(string: "https://ios-test.microcms-dev.net/api/v1/articles")!)
request.setValue("d12ec097-3de6-4e38-985f-28f5865b7fd3", forHTTPHeaderField: "X-MICROCMS-API-KEY")
URLSession.shared.dataTask(with: request) { (data, response, error) in
guard let data = data else { return }
do {
let object = try JSONSerialization.jsonObject(with: data, options: []) as! Dictionary<String, Any>
let contents = object["contents"] as! Array<Dictionary<String, Any>>
let articles = contents.map { (content) -> Article in
//共通化処理を利用========
return self.objectToArticle(object: content)
//ここまで========
}
DispatchQueue.main.async {
self.articles = articles
}
} catch let e {
print(e)
}
}.resume()
}
//追加ここから============
func loadDetail(contentId: String) {
var request = URLRequest(url: URL(string: "https://ios-test.microcms-dev.net/api/v1/articles/\(contentId)")!)
request.setValue("d12ec097-3de6-4e38-985f-28f5865b7fd3", forHTTPHeaderField: "X-MICROCMS-API-KEY")
URLSession.shared.dataTask(with: request) { (data, response, error) in
guard let data = data else { return }
do {
let object = try JSONSerialization.jsonObject(with: data, options: []) as! Dictionary<String, Any>
let article = self.objectToArticle(object: object)
DispatchQueue.main.async {
self.article = article
}
} catch let e {
print(e)
}
}.resume()
}
func clearArticle() {
self.article = nil
}
//ここまで============
//共通化処理を追加ここから==============
private func objectToArticle(object: Dictionary<String, Any>) -> Article {
let id = object["id"] as! String
let title = object["title"] as! String
let mainVisual = object["main_visual"] as! Dictionary<String, Any>
let imageUrl = mainVisual["url"] as! String
let contents = object["contents"] as! [Dictionary<String, Any>]
let publishedAt = self.iso8601DateFormatter.date(from: object["publishedAt"] as! String)
return Article(id: id, publishedAt: publishedAt!, title: title, imageUrl: URL(string: imageUrl)!, contents: contents)
}
//ここまで==============
}
@Published
付きの変数でarticle
を新たに宣言し、単独のコンテンツを外に通知できるようにしました。
実際のコンテンツ詳細の取得はloadDetail
で行います。loadDetail
は引数としてコンテンツのidを受け取るため、これらメソッドと変数を使ってView側の実装を進めていきます。
なお、取得したデータをArticle
オブジェクトに変換する処理はload
とloadDetail
で共通のため、新たにobjectToArticle
という共通処理を行うメソッドもここでは用意しています。
本文表示の準備を行う
次に、少し立ち止まってModel.swift
に書かれたArticle
を確認してみましょう。
第1回の記事の時点では以下のような実装がされていました。
struct Article: Identifiable, Equatable {
var id: String
var publishedAt: Date
var title: String
var imageUrl: URL
var contents: [Dictionary<String, Any>]
static func == (lhs: Article, rhs: Article) -> Bool {
lhs.id == rhs.id
}
}
ここで、詳細画面を表示していくにあたり重要なのはcontents
変数です。
今回のサンプルではcontents
部分にmicroCMSの繰り返しフィールドを利用しており、この部分に配列形式で本文が入ってきます。
//詳細APIのレスポンス例
{
"id": "b7hbsnrlx4",
"createdAt": "2021-03-06T15:58:35.152Z",
"updatedAt": "2021-03-06T15:58:35.152Z",
"publishedAt": "2021-03-06T15:58:35.152Z",
"revisedAt": "2021-03-06T15:58:35.152Z",
"title": "おいしいリンゴが採れました",
"main_visual": {
"url": "https://images.microcms-dev-assets.net/assets/fe5cec22561f46a29228d1d04b1afb4f/22c0d2b6cee8492ba58b76fac93b5cd3/apple.jpg",
"height": 533,
"width": 800
},
"contents": [
{
"fieldId": "heading",
"text": "おいしいリンゴのご紹介"
},
{
"fieldId": "image",
"image": {
"url": "https://images.microcms-dev-assets.net/assets/fe5cec22561f46a29228d1d04b1afb4f/af48533414ce441aa5b682c8a1c9b4e2/apple_with_hand.jpg",
"height": 1200,
"width": 800
}
},
{
"fieldId": "text",
"text": "あま〜いリンゴができました!"
}
]
}
今回の繰り返しフィールドでは以下3つのカスタムフィールドを作成していました。
- 見出し:heading
- 画像:image
- 本文テキスト:text
上記のようにmicroCMSのレスポンスではfieldId
属性によってどのフィールドが入力されていたかが把握できます。
ここまでを踏まえて、Article
のcontents
の型をDictonary
の配列からArticleContent
の配列に変更します。
struct Article: Identifiable, Equatable {
var id: String
var publishedAt: Date
var title: String
var imageUrl: URL
var contents: [ArticleContent]
static func == (lhs: Article, rhs: Article) -> Bool {
lhs.id == rhs.id
}
}
protocol ArticleContent {}
struct HeadingContent: ArticleContent {
var text: String
}
struct ImageContent: ArticleContent {
var imageUrl: URL
}
struct TextContent: ArticleContent {
var text: String
}
合わせて、MicroCMSRequester
のパース部分も変更を行います。
上記のfieldId毎に別の型のデータに変換を行う実装です。
class MicroCMSRequester: ObservableObject {
//.....
private func objectToArticle(object: Dictionary<String, Any>) -> Article {
let id = object["id"] as! String
let title = object["title"] as! String
let mainVisual = object["main_visual"] as! Dictionary<String, Any>
let imageUrl = mainVisual["url"] as! String
//編集ここから=====================
let rawContents = object["contents"] as! [Dictionary<String, Any>]
let contents = rawContents.map { content -> ArticleContent in
let fieldId = content["fieldId"] as! String
switch fieldId {
case "text":
return TextContent(text: content["text"] as! String)
case "heading":
return HeadingContent(text: content["text"] as! String)
case "image":
let image = content["image"] as! Dictionary<String, Any>
return ImageContent(imageUrl: URL(string: image["url"] as! String)!)
default:
return TextContent(text: "")
}
}
//ここまで=====================
let publishedAt = self.iso8601DateFormatter.date(from: object["publishedAt"] as! String)
return Article(id: id, publishedAt: publishedAt!, title: title, imageUrl: URL(string: imageUrl)!, contents: contents)
}
}
View側の実装を行う
これで準備が整いました。詳細画面の実装を進めていきましょう。
詳細画面では先ほど用意した記事詳細を取得するためのMicroCMSRequester
のloadDetail
メソッドを画面表示の際(onAppear
)に利用してデータ取得を行います。
画面非表示(戻るボタンの押下など)の際(onDisappear
)には、別画面での記事表示を考慮して内容をクリアしておきましょう。
そのほか、本文の表示部分には今回はForEach
Viewを使う形としました。
内容的には非常にシンプルで、ArticleContent
protocolの継承クラス(HeadingContent
、TextContent
、ImageContent
)に応じて表示するViewを切り替えています。
import SwiftUI
struct ArticleDetail: View {
var contentId: String
@ObservedObject var microCMS = MicroCMSRequester()
init(contentId: String) {
self.contentId = contentId
}
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading) {
if let article = microCMS.article {
//メインビジュアル
UrlImage(url: article.imageUrl)
.frame(height: 200)
VStack(alignment: .leading) {
//タイトル
Text(microCMS.article?.title ?? "").font(.title)
Divider()
//本文
ForEach(0..<article.contents.count) { num in
let content = article.contents[num]
if let heading = content as? HeadingContent {
Text(heading.text).font(.title2).padding(.top, 10)
} else if let text = content as? TextContent {
Text(text.text).padding(.top, 10)
} else if let image = content as? ImageContent {
UrlImage(url: image.imageUrl)
}
}
}
.padding()
.frame(maxWidth: .infinity)
Spacer()
}
}
}
.ignoresSafeArea()
.onAppear {
self.microCMS.loadDetail(contentId: contentId)
}
.onDisappear {
self.microCMS.clearArticle()
}
}
}
struct ArticleDetail_Previews: PreviewProvider {
static var previews: some View {
ArticleDetail(contentId: "b7hbsnrlx4")
}
}
ここまでの実装で一覧画面→詳細画面の遷移と、詳細画面内の具体的な表示を実装することができました。
実行してその挙動を確認してみてください!
画像の表示を最適化する
最後に、おまけコンテンツです。
ここまで一覧画面と詳細画面を実装してきましたが、画像の表示はクライアント側のImage.resizable()
で表示を変えているだけなため以下の問題がありました。
- 画像ダウンロード量が多い(特に一覧画面)
- 画像の縦横比が変わってしまう
こうした問題への対処としてmicroCMSでは画像に対してもAPIを用意しています。
画像APIのドキュメントはこちら。
実例を見ていきましょう。
こちらの画像はmicroCMSにアップロードしたものでありそのままでは容量は約10MB、縦横サイズは4128 × 2752と非常に大きな画像です。
https://images.microcms-assets.io/assets/f5d83e38f9374219900ef1b0cc4d85cd/e11f98135bcf42c99c2d83aedb971a66/example.jpg
このままiOSアプリなどのクライアントで直接この画像を読み取ってはページの表示速度や転送量への影響(ギガが減る...!)の観点で大きな問題となります。
こういった場合に画像URLにパラメータを付与するだけで簡単に画像の編集を行うことができます。
例えば同じ画像の横幅を小さくしてみましょう。先ほどのURLの最後に横幅の編集のためのw
パラメータを付与するだけです。
https://images.microcms-assets.io/assets/f5d83e38f9374219900ef1b0cc4d85cd/e11f98135bcf42c99c2d83aedb971a66/example.jpg?w=200
たったこれだけで元々の10分の1以下の縦横サイズにリサイズでき、容量も約13KBとなんと1000分の1程度に抑えることができました。
内部的にはImgixを利用しているため、切り抜きのためのfit
や、フォーマット変換(jpg、png、webpなどへの変換)を行うfm
など非常に多くのURLパラメータが利用可能です。
それでは画像APIを利用して、さきほどの一覧画面と詳細画面の表示を最適化しましょう。
まずは画像の表示を行っていたUrlImage
に機能を追加します。
具体的には生成時にheight
、width
、fit
値を受け取るようにし、この値を画像取得の際にも利用します。
struct UrlImage: View {
@ObservedObject private var requester = ImageRequester()
var url: URL
var height: Int? //追加
var width: Int? //追加
var fit: String? //追加
var body: some View {
Image(uiImage: requester.image)
.onAppear {
//URLパラメータを付与する形に修正
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)
components?.queryItems = [
height.map { URLQueryItem(name: "h", value: String($0)) },
width.map { URLQueryItem(name: "w", value: String($0)) },
fit.map { URLQueryItem(name: "fit", value: $0) }
].compactMap { $0 }
if let component = components, let url = component.url {
requester.load(url: url)
}
}
}
}
ここまで準備ができたらView側の修正を行います。
まずは一覧画面の中で行部分を表示しているArticleRow
です。
50×50サイズで表示し、範囲外となる場合は切り抜く(crop
)よう引数で渡して設定します。
struct ArticleListRow: View {
var article: Article
var body: some View {
HStack {
UrlImage(url: article.imageUrl, height: 50, width: 50, fit: "crop") //ここの呼び出し時にパラメータを付与
VStack(alignment: HorizontalAlignment.leading) {
Text(article.title)
Text(article.publishedAt.description)
}
Spacer()
}
}
}
これだけで先ほどまでいびつだった一覧画面での画像表示が修正できます。
続いて詳細画面です。
こちらは画面上部に幅いっぱいの画面表示を行っているためGeometryReader
を使って画面幅を取得しています。
また、今回は本文内の画像サイズを横幅を200に固定することとしました。
import SwiftUI
struct ArticleDetail: View {
var contentId: String
@ObservedObject var microCMS = MicroCMSRequester()
init(contentId: String) {
self.contentId = contentId
}
var body: some View {
ScrollView(.vertical) {
GeometryReader { geometry in
VStack(alignment: .leading) {
if let article = microCMS.article {
//メインビジュアル
UrlImage(url: article.imageUrl, height: 200, width: Int(geometry.size.width), fit: "crop")
VStack(alignment: .leading) {
//タイトル
Text(microCMS.article?.title ?? "").font(.title)
Divider()
//本文
ForEach(0..<article.contents.count) { num in
let content = article.contents[num]
if let heading = content as? HeadingContent {
Text(heading.text).font(.title2).padding(.top, 10)
} else if let text = content as? TextContent {
Text(text.text).padding(.top, 10)
} else if let image = content as? ImageContent {
UrlImage(url: image.imageUrl, width: 200)
.frame(width: 200)
}
}
}
.padding()
.frame(maxWidth: .infinity)
Spacer()
}
}
}
}
.ignoresSafeArea()
.onAppear {
self.microCMS.loadDetail(contentId: contentId)
}
.onDisappear {
self.microCMS.clearArticle()
}
}
}
実装は以上です。この状態で実行すると先ほどより適切な形での画像表示になっています。
microCMSの画像編集機能がSwiftUIにおいても便利にお使いいただけることがわかっていただけましたら幸いです!
それでは、第2回の記事はここまでとさせていただきます。
次回は記事が多い場合のページング処理について、解説を進めていきます。