microCMS

【第2回】SwiftUIとmicroCMSでつくるiOSメディアアプリ - 詳細画面の表示と画像表示の最適化

チュートリアル
松田 承一

前回のチュートリアル記事ではmicroCMSのセットアップとSwiftUIを使った記事の一覧表示を行いました。
今回はこの続きとして詳細画面の開発を進めていきます。

全5回の内容

本記事は全5回から成るSwiftUI + microCMSのチュートリアル記事です。

  1. microCMSのセットアップとSwiftUIを使った記事一覧表示
  2. 繰り返しフィールドを活用した詳細ページの開発 ← 本記事です
  3. offset/limitパラメータによる一覧表示のページング(作成中...)
  4. 検索機能の導入(作成中...)
  5. コンテンツ参照を使ったカテゴリ機能の追加(作成中...)

詳細画面への遷移

それでは作業を進めましょう。
まずおさらいですが、前回は以下のような一覧画面の表示まで実装しました。

今回はまず、こちらの一覧画面の行をタップすると詳細画面に遷移するようにしていきます。

最初に遷移先の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側に遷移用の実装を追加します。
以下のようにNavigationViewNavigationLinkを追加してください。
遷移時にはパラメータとしてコンテンツの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-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-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オブジェクトに変換する処理はloadloadDetailで共通のため、新たに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属性によってどのフィールドが入力されていたかが把握できます。
ここまでを踏まえて、Articlecontentsの型を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側の実装を行う

これで準備が整いました。詳細画面の実装を進めていきましょう。
詳細画面では先ほど用意した記事詳細を取得するためのMicroCMSRequesterloadDetailメソッドを画面表示の際(onAppear)に利用してデータ取得を行います。
画面非表示(戻るボタンの押下など)の際(onDisappear)には、別画面での記事表示を考慮して内容をクリアしておきましょう。

そのほか、本文の表示部分には今回はForEachViewを使う形としました。
内容的には非常にシンプルで、ArticleContentprotocolの継承クラス(HeadingContentTextContentImageContent)に応じて表示する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に機能を追加します。
具体的には生成時にheightwidthfit値を受け取るようにし、この値を画像取得の際にも利用します。

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回の記事はここまでとさせていただきます。

次回は記事が多い場合のページング処理について、解説を進めていきます。

ABOUT ME

松田 承一
ウォンタ株式会社の代表 / 家族=👨‍👩‍👧 / ヤフー→大学教員など→現職 / 管理画面付きAPIがすぐに作れるmicroCMSというサービス作ってます。

microCMSとは

  1. 開発者、編集者どちらも分かりやすい管理画面

  2. 細かな権限管理や豊富な外部サービス・データ連携

  3. 安心の日本製・日本語でのチャットサポート