every Tech Blog

株式会社エブリーのTech Blogです。

DELISH KITCHEN の Android アプリに記事を追加した話

DELISH KITCHEN はレシピを動画でわかりやすく基本的な料理からアレンジまで様々なレシピを公開しています。
実はレシピ動画以外にも、季節にそったおすすめレシピ、素材についての解説、料理に役立つ情報などが記事にまとめられ公開されています。ご存知でしたか?

例えばこういった記事です。

今回は、この記事を Android アプリで閲覧できるようにした話をしたいと思います。

記事とは

まずは一例を見ていただければと思います。

目次 本文
記事の概要と見出し
おいしそうな出汁…

レシピ動画は料理の手順を簡単に把握しやすいのですが、一方で素材の魅力にふれるにはあまり適していない表現方法かと思います。記事では、そういった素材の魅力であったり、より詳細な解説や料理に役立つ情報などを紹介しています。

粉末だしとはその名の通り、鰹節や昆布などの素材を乾燥させて粉末状に加工したものです。製造過程で熱を加えていないため、より素材の自然な風味を感じられます。

なるほど…ふだんは顆粒だしを使っているのですが、今度は粉末だしを使ってみようかな?

記事のデータ

早速ですが記事について技術面の話をしたいと思います。
まずは記事を構成するデータの構造についてです。

※ 以降の内容は具体的な処理については割愛し、掲載するデータもイメージとなります

先に紹介した記事の内容は記事 API を介して JSON 形式でデータを取得します。

{
  "article_id": 123456,
  "title": "だし汁とは?簡単なだしの作り方もご紹介!",
  "description": "和食におけるだし汁の役割はとても大きく…",
  "thumbnail": "https://tech.blog.com/images/header.png",
  "created_at": "2022-06-02T16:00:00Z",
  "contents": [
      {
        "type": "image",
        "image_url": "https://tech.blog.com/images/main.jpeg",
      },
      {
        "type": "header1",
        "text": "和食に欠かせないだし汁とは?"
      },
      {
        "type": "paragraph",
        "text": "だしにはさまざまな種類がありますが、中でも一般的によく使われている素材が鰹節や昆布…"
      },
      ...
}

記事は titledescription など記事共通の要素の他に、本文などが含まれる contents で構成されています。 contents は記事の内容を柔軟に表現できるよう可変長になっています。その中身は、要素を区別する type と内容(テキストや URL など)の組み合わせで構成されたオブジェクトです。

これらのデータはアプリ側で扱いやすいように type で区別して各要素に変換します。

sealed class Content {
    // 見出しの属性として
    sealed class Headline : Content() {
        abstract val headline: String
        abstract val subHeadline: String?
    }

    // 見出し1
    data class Headline1(
        override val headline: String,
        override val subHeadline: String?,
    ) : Headline()

    // 見出し2
    data class Headline2(
        override val headline: String,
        override val subHeadline: String?,
    ) : Headline()

    // 段落
    data class Paragraph(
        val text: String,
    ) : Content()

    // 画像
    data class Image(
        val imageUrl: String,
    ) : Content()

    ...
}

パースされた要素はリスト(RecyclerView)に追加します。

リストへの追加

各要素を RycyclerView に add するタイミングで type に応じた ViewHolder に変換して追加します。
以下、Groupie を使って作成した Adapter と ViewHolder の一部です。

※ Groupie に関する詳細は github を参照してください

class ArticleAdapter(
    private val listener: Listener
) : GroupAdapter<GroupieViewHolder>() {
    ...
    interface ContentItem
    interface SectionItem : ContentItem
    interface TitleItem : ContentItem
    interface SubTitleItem : ContentItem
    interface DescriptionItem : ContentItem
    interface OtherItem : ContentItem

    private class ContentHeadline1Item(
        private val item: Content.Headline1
    ) : BindableItem<ItemContentHeadline1Binding>(), TitleItem {
        override fun getLayout() =
            R.layout.item_content_headline1

        override fun initializeViewBinding(view: View) =
            ItemContentHeadline1Binding.bind(view)

        override fun bind(viewBinding: ItemContentHeadline1Binding, position: Int) {
            viewBinding.title.text = item.headline
        }
    }
    ...
}

ContentHeadline1Item は ViewHolder です。このように、要素に合わせた ViewHolder を用意して各要素を変換します。

ここで ContentItemSectionItem などの interface が気になった方がいらっしゃるかもしれません。この interface については後ほど触れたいと思います。

要素間のマージン

ViewHolder に変換した要素は ViewHolder 内で指定したレイアウトが適用されます。下図のようにレイアウト内であれば目次のタイトルやリストなどマージンの調整は容易ですね。では、要素間のマージンはどうでしょうか?

目次は通常の layout.xml 内で完結できるので簡単です

見出しなら上部に 24 dp、画像なら上下に 16 dp のように要素ごとに固定のマージンなら単純だったのですが、記事の場合は前の要素によってマージンを変更する必要がありました。ひと手間かける必要がありそうです。

結論から言えば、今回の要素間のマージンは RecyclerView の ItemDecoration を使って解決しました。ItemDecoration はリストに追加されるアイテムにオフセットを追加する便利な仕組みを持っています。

この ItemDecoration の実装で先ほど触れた ContentItem などの interface が役に立ちます。
準備として、各 ViewHolder に適した interface を関連付けておきます。

以下、実装の一部です。

class MarginDecoration(
    resources: Resources,
    @IdRes private val space: Int
) : RecyclerView.ItemDecoration() {
    // space を元に必要な marginXX を定義
    ...

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val itemCount = parent.adapter?.itemCount?.let { it - 1 } ?: 0
        val position = parent.getChildAdapterPosition(view)

        // 最後の要素は下にもマージンを設ける
        val bottom = if (itemCount == position) margin40 else 0

        // 最初の要素は上のマージンを固定
        val top = if (position == 0) margin24 else null

        // 上のマージンは前の要素との関係によって決定するため、一つ前の ViewHolder を取得する
        val previousHolder =
            if (position > 0) (parent.adapter as? ArticleAdapter)?.findContentItemBy(position - 1) else null
        val currentHolder = parent.getChildViewHolder(view) as? GroupieViewHolder

        // 前の要素との関係でマージンを変える判定をここで行う
        when (val item = currentHolder?.item) {
            is TitleItem ->
                outRect.set(0, top ?: margin40, 0, bottom)
            is SubTitleItem ->
                when (previousHolder) {
                    is SectionItem -> margin40
                    else -> margin24
                }.let {
                    outRect.set(0, top ?: it, 0, bottom)
                }
            ...
            is OtherItem ->
                outRect.set(0, top ?: margin24, 0, bottom)
            else -> {
                // nothing
            }
...
}

getItemOffsets() はリストに要素が追加されるタイミングで要素にオフセットを追加することが可能です。パラメータの outRectset(left, top, right, bottom) することで要素にマージンを適用します。

outRect.set() する際に現在の要素が SubTitleItem で前の要素が SectionItem なら 40 dp …と考えられる組み合わせを条件で定義して適切なマージンを設定しました。

以下、記事を実装した結果です。

目次 本文
アプリ版の目次部分
アプリ版の本文

ContentItem などの interface を用意したことで5種類の interface といくつかの要素を考慮するだけで済みました。新しく要素を追加する場合も適した interface を関連付けるだけで済みます。記事の要素として用意された type は10種類以上あるので、個別に対応することを考えると…脳が震えます。

最後に

こうして DELISH KITCHEN の Android アプリでも記事が閲覧できるようになりました。一生懸命作った機能ですし、読み物としても面白いと思うので、ぜひ色々な記事を読んでみてほしいです。