RecBole を用いてクックパッドマートのデータに対する50以上のレコメンドモデルの実験をしてみた

こんにちは。研究開発部の深澤(@fufufukakaka)です。

本記事では最近面白いなと思って watch しているレコメンド系のプロジェクト RecBole を紹介いたします。また、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って数多くのレコメンドモデルを試す実験も行いました。その結果も合わせて紹介します。

TL;DR:

  • レコメンドモデルは作者実装に安定性がなく、またモデルをどのように評価したかも基準がバラバラで、再現性が難しいとされている(from RecSys 2019 Best Paper)
  • 再現性に取り組むプロジェクトとして 2020年12月に始まった RecBole がある。 RecBole を利用することでなんと 50個以上のレコメンドモデルを大体1コマンドで試せる
  • クックパッドマートでユーザに対してアイテムをレコメンドするシチュエーションを想定し実験を行った。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できた

正しく強いレコメンドモデルを探すのは難しい

サービスの中で機械学習といえばレコメンド、といわれる機会は非常に多いかと思います。が、レコメンドは機械学習の中ではかなり特殊な問題設定です。クラス分類したり回帰したり、と様々な解き方をすることができるタスクなのがその要因です。「ユーザがこのアイテムを買ってくれる確率」を推定しても良いし「ユーザが好きなアイテムのランキング」を予測しても良い。そもそもアイテムの数が数万種類くらいある中で「このユーザはこれを買ってくれそう」を予測するのは非常に難しいです。つまり、レコメンド系は実社会サービスでの需要が高く非常に難しい問題、と言えます。

おかげでたくさんの研究が日々発表されています。それ自体は素晴らしいことです。RecSys というレコメンドのみを取り扱う国際会議も存在しています。最近ではDeep Learning を活用した研究が殆どを占めています。

ですが、この日々公開されている研究には再現性がないことが指摘されています。2019の RecSys ベストペーパーは「Are We Really Making Much Progress? A Worrying Analysis of Recent Neural Recommendation Approaches」でした。「本当にニューラルネットワーク系の手法で精度は上がっているのか?」というこの論文では衝撃の事実が明かされており

  • トップ会議(KDD, SIGIR, WWW, RecSys)のDNN関連研究18本を追試した
    • 18本のうち、現実的な努力を行った上で再現できたのが7本(半分以下!)
      • (RecSysでの発表によると、)実装が再現できない場合は、実装を原著者らに問い合わせて1ヶ月待った
    • 再現できたとしても 6/7がkNNベース(シンプルなモデル)+ハイパーパラメータ最適化に負けてしまった
    • 残りの1つもDNNではない線形の手法を調整したものに負ける場合もあった

(refs. https://qiita.com/smochi/items/98dbd9429c15898c5dc7 )

国際会議で「state-of-the-artだ」と主張している論文の殆どが、実際には10年以上前から存在しているシンプルな手法に負けてしまう、というサーベイ結果が出ており、非常に面白い論文でした。

この論文が示したように、レコメンドの研究は数多く発表されているものの殆どの実装に再現性がなく、また正しい比較ができていない、というのが現状です。当然ですが、論文内で提示されている GitHub リポジトリの実装は人によってまちまちです。再現実装は再利用が可能なものから環境構築自体が困難なものまで色々あります。前提としてコードを上げてくれることは非常にありがたいのですが、それぞれの手法で土台を揃えた実験を行うのはそもそもが難しい状況です。

RecBoleについて

RecBole は中国人民大学・北京大学の研究室が共同で始めたプロジェクトのようで、去年の11月に arxiv に登場しました。今年の8月に提供しているモジュールがv1を迎えて、本格的に色々な人が利用するようになったようです。

RecBole 最大の魅力は、上述してきた再現性の難しいレコメンドモデルを統一したインタフェースで実装し、比較を容易にしているところにあります。そして実装されているモデル、適用できるデータセットの数が凄まじいです。モデルは現時点で70以上(モデルリストがすごい )、データセットは20以上のものについて即座に試せます。どれくらい即座に試せるかと言うと

pip install recbole
python run_recbole.py --model=<your favorite model> --dataset_name ml-100k

これだけで、レコメンド界隈の中で最も有名なベンチマークである MovieLens-100k データセットに対して70以上のモデルを即座に(追加の設定が必要なやつもありますが)試せます。これだけのモデル・データを試すことができる環境はそうないと思われます。また70以上の収録されているモデルたちは全て PyTorch ベースで丁寧に再実装が行われており信頼性は非常に高いです。predict関数などの基本的なインタフェースは統一されており、実験のし易い環境が整えられています。

RecBole を自分たちのデータで使えるようにする

実際に RecBole を使えるようにするためにはどうするばよいのか、について簡単にまとめてみました。

  1. ユーザとアイテムのアクション履歴をまとめたデータを用意する
  2. データをコントロールするクラスを用意する
  3. 配布されているスクリプトを使ってデータを RecBole が読める形式に変換する
  4. 学習に必要な設定ファイルを用意する
  5. 学習スクリプトを走らせる

1. ユーザとアイテムのアクション履歴をまとめたデータを用意する

自分の使いたいデータを持ってきて、以下のようなファイルで保存しておきます。

今回はクックパッドマートを対象としてデータを作りました。interact.csv ではあるユーザがあるアイテムを購入したログが表現されています。MovieLens のような Rating (Explicit Feedback)のついていない Implicit Feedback なデータセットです。

なお、ここで紹介している user_id, item_id などはいずれもダミーとなっています。

interact.csv

user_id,item_id,timestamp(Unix timestamp)
1,1,1630461974
2,2,1630462246
3,2,1630462432

items.csv

item_id,item_name,item_category_id
1,豚バラ,9
2,にんじん,7

users.csv

user_id,feature1,feature2
1,286,130
2,491,3
3,342,32

2. データをコントロールするクラスを用意する

続いて、RecBole 内でこれらのデータを扱うためのクラスを用意します。基本的には BaseDataset と同じインタフェースを用意して、その内部をデータに合わせて調整するような作業になります。

import os

import pandas as pd
from src.dataset.base_dataset import BaseDataset  # https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/src/base_dataset.py をコピーして所定の場所に配置しておく

class CookpadMartDataset(BaseDataset):
    def __init__(self, input_path, output_path):
        super(CookpadMartDataset, self).__init__(input_path, output_path)
        self.dataset_name = "ckpd_mart"

        # input_path
        self.interact_file = os.path.join(self.input_path, "interact.csv")
        self.item_file = os.path.join(self.input_path, "items.csv")
        self.user_file = os.path.join(self.input_path, "users.csv")

        self.sep = ","

        # output_path
        output_files = self.get_output_files()
        self.output_interact_file = output_files[0]
        self.output_item_file = output_files[1]
        self.output_user_file = output_files[2]

        # selected feature fields
        # 型について -> https://recbole.io/docs/user_guide/data/atomic_files.html#format
        self.interact_fields = {
            0: "user_id:token",
            1: "item_id:token",
            2: "timestamp:float",
        }

        self.item_fields = {
            0: "item_id:token",
            1: "item_name:token",
            2: "item_category_id:token"
        }

        self.user_fields = {
            0: "user_id:token",
            1: "feature1:token",
            2: "feature2:token",
        }

    def load_inter_data(self):
        return pd.read_csv(self.interact_file, delimiter=self.sep, engine="python")

    def load_item_data(self):
        return pd.read_csv(self.item_file, delimiter=self.sep, engine="python")

    def load_user_data(self):
        return pd.read_csv(self.user_file, delimiter=self.sep, engine="python")

3. 配布されているスクリプトを使ってデータを RecBole が読める形式に変換する

https://github.com/RUCAIBox/RecSysDatasets/blob/master/conversion_tools/run.py

ここで公開されているスクリプトを使って RecBole 内で利用できる形式の Atomic Files に変換します。 refs

python src/dataset/convert.py --dataset ckpd_mart \
--input_path data/mart_data --output_path dataset/ckpd_mart \
--convert_inter --convert_item --convert_user

すると ckpd_mart.inter ckpd_mart.item ckpd_mart.user というファイルが所定の場所に配備されます。これでデータの準備は完了です。

4.学習に必要な設定ファイルを用意する

https://recbole.io/docs/user_guide/config_settings.html

RecBole が用意してくれている config 設定を読みながら自分のデータに合わせた設定ファイルを書いていきます。

# general
gpu_id: 0
use_gpu: False  # GPUを使う時はTRUEにする
seed: 2020
state: INFO
reproducibility: True
data_path: 'dataset/'  # 使うデータが格納されている場所
checkpoint_dir: 'saved/'  # モデル保存先
show_progress: True
save_dataset: False  # True にすればtrain, valid, test で使ったデータを保存してくれる
save_dataloaders: False

# Atomic File Format
field_separator: "\t"
seq_separator: "@" # 文字列があった場合この文字で区切られる。特徴量読み込み時にバグってしまう可能性があるため、できるだけデータを事前に処理しておき絶対に出現しない保障が取れている記号を書くべき(日本語の場合)

# Common Features
USER_ID_FIELD: user_id
ITEM_ID_FIELD: item_id
RATING_FIELD: ~  # implicit feedback の場合
TIME_FIELD: timestamp

# Selectively Loading
# 使うデータだけを選んで loadします
load_col:
    inter: [user_id, item_id, timestamp]
    user: [user_id, feature1, feature2]
    item: [item_id, item_name, item_category_id]
unused_col:  # データとしては読み込むけど学習には使いたくないカラムはここで指定する
    inter: [timestamp]

# Training and evaluation config
epochs: 50
stopping_step: 10  # 10 step valid_metric が改善しない場合は止める
train_batch_size: 4096
eval_batch_size: 4096
neg_sampling:  # implicit feedbackなデータを扱っていて positive,negative両方のラベルが必要な手法を試す際に、negative samplingすることでデータを用意できる
    uniform: 1
eval_args:
    group_by: user  # user 単位でアイテムを集約して評価に使う。基本的にこれ以外使うことはない
    order: TO  # Temporal Order。時系列順で train, valid, test を分けてくれる
    split: {'RS': [0.8,0.1,0.1]}  # 80%, 10%, 10% で分けてくれる
    mode: full
metrics: ['Recall', 'MRR', 'NDCG', 'Hit', 'Precision']
topk: 10
valid_metric: MRR@10  # この指標をtrackする
metric_decimal_place: 4

5. 学習スクリプトを実行する

おまたせしました。あとは実験をするだけです。

モデルによって与えるパラメータが微妙に違ったりするので、そこを吸収する以下のようなスクリプト(run_experiment.py)を用意して

import click
from recbole.quick_start import run_recbole

@click.command()
@click.option(
    "-m",
    "--model_name",
    required=True,
    type=str,
    help="Model Name(see recbole's model list)",
)
@click.option(
    "-d",
    "--dataset_name",
    required=True,
    type=str,
    help="Dataset Name(your custom dataset name or recbole's dataset name)",
)
@click.option(
    "-c",
    "--config_file_list",
    required=True,
    nargs=-1,
    help="config file path",
)
def main(model_name, dataset_name, config_file_list):
    if model_name in [
        "MultiVAE",
        "MultiDAE",
        "MacridVAE",
        "RecVAE",
        "GRU4Rec",
        "NARM",
        "STAMP",
        "NextItNet",
        "TransRec",
        "SASRec",
        "BERT4Rec",
        "SRGNN",
        "GCSAN",
        "GRU4RecF",
        "FOSSIL",
        "SHAN",
        "RepeatNet",
        "HRM",
        "NPE",
    ]:
        # これらは non-sampling method
        # https://recbole.io/docs/user_guide/model/general/macridvae.html などを参照
        parameter_dict = {
            "neg_sampling": None,
        }
        run_recbole(
            model=model_name,
            dataset=dataset_name,
            config_file_list=config_file_list,
            config_dict=parameter_dict,
        )
    else:
        run_recbole(
            model=model_name, dataset=dataset_name, config_file_list=config_file_list
        )

if __name__ == "__main__":
    main()

あとは python run_experiment.py --dataset_name ckpd_mart --model_name <your favorite model> --config_files config/ckpd_mart.yml するだけです。お疲れさまでした。

RecBole を試してみた結果

ここまで頑張って用意した土台を使って、早速 RecBole に収録されているモデルをクックパッドマートの購入履歴データ(2021年9月~10月)で試してみました。先程のスクリプトの引数を変えるだけで次々と実験を行うことができます。追加の設定ファイルが必要なものを除いて、50前後のレコメンドモデルを実験することができました。

それでは以下に結果の表を示します。モデル名と各指標、タイプ(行動データしか使わないgeneral・別の情報を使うcontext-aware、時間情報を用いるsequential)、論文名が一覧になっています。

モデル名 recall@10 mrr@10 ndcg@10 hit@10 precision@10 タイプ 論文名
RecVAE 0.2754 0.2626 0.2474 0.314 0.0367 general RecVAE: A New Variational Autoencoder for Top-N Recommendations with Implicit Feedback
MacridVAE 0.2651 0.2488 0.2364 0.303 0.0347 general MACRo-mIcro Disentangled Variational Auto-Encoder
NAIS 0.2324 0.2452 0.2244 0.2698 0.0325 general Neural Attentive Item Similarity Model for Recommendation
NNCF 0.2248 0.1755 0.1767 0.2567 0.0282 general A Neural Collaborative Filtering Model with Interaction-based Neighborhood
RepeatNet 0.2725 0.1468 0.1766 0.2725 0.0272 sequential RepeatNet: A Repeat Aware Neural Recommendation Machine for Session-based Recommendation.
NeuMF 0.2344 0.1638 0.1699 0.268 0.0304 general Neural Collaborative Filtering
LINE 0.1859 0.1556 0.1529 0.2156 0.0237 general LINE: Large-scale Information Network Embedding
BPR 0.1789 0.1455 0.1442 0.2088 0.0223 general BPR Bayesian Personalized Ranking from Implicit Feedback
SHAN 0.1738 0.1189 0.132 0.1738 0.0174 sequential SHAN: Sequential Recommender System based on Hierarchical Attention Network.
Item2vec 0.121 0.1183 0.112 0.1372 0.0148 general Item 2 Vec-based Approach to a Recommender System
DGCF 0.1703 0.0965 0.1099 0.1931 0.0201 general Disentangled Graph Collaborative Filtering
FFM 0.187 0.0922 0.1096 0.2099 0.0225 context-aware Field-aware Factorization Machines for CTR Prediction
FPMC 0.151 0.0935 0.107 0.151 0.0151 sequential Factorizing personalized Markov chains for next-basket recommendation
NARM 0.1664 0.0847 0.1039 0.1664 0.0166 sequential Neural Attentive Session-based Recommendation
LightGCN 0.1549 0.0794 0.0952 0.1715 0.0174 general LightGCN: Simplifying and Powering Graph Convolution Network for Recommendation
NGCF 0.126 0.0823 0.089 0.1416 0.0148 general Neural Graph Collaborative Filtering
SASRec 0.1142 0.0657 0.0771 0.1142 0.0114 sequential Self-Attentive Sequential Recommendation
HRM 0.0992 0.0684 0.0756 0.0992 0.0099 sequential HRM: Learning Hierarchical Representation Model for Next Basket Recommendation.
EASE 0.1205 0.0752 0.0751 0.1559 0.0204 general Embarrassingly Shallow Autoencoders for Sparse Data
MultiVAE 0.1113 0.0681 0.0751 0.1245 0.0126 general Variational Autoencoders for Collaborative Filtering
NPE 0.123 0.0597 0.0744 0.123 0.0123 sequential NPE: Neural Personalized Embedding for Collaborative Filtering
MultiDAE 0.1011 0.0596 0.0671 0.1127 0.0114 general Variational Autoencoders for Collaborative Filtering
SRGNN 0.1115 0.0515 0.0654 0.1115 0.0112 sequential Session-based Recommendation with Graph Neural Networks
ENMF 0.1075 0.0545 0.0629 0.1261 0.0131 general Efficient Neural Matrix Factorization without Sampling for Recommendation
DCN 0.1085 0.0508 0.06 0.1255 0.013 general Deep & Cross Network for Ad Click Predictions
FOSSIL 0.087 0.0481 0.0572 0.087 0.0087 sequential FOSSIL: Fusing Similarity Models with Markov Chains for Sparse Sequential Recommendation.
ItemKNN 0.0649 0.0666 0.0534 0.094 0.0126 general Item-based top-N recommendation algorithms
DeepFM 0.0873 0.0347 0.0442 0.1029 0.0106 context-aware DeepFM: A Factorization-Machine based Neural Network for CTR Prediction
PNN 0.0851 0.0353 0.0441 0.0994 0.0102 context-aware Product-based neural networks for user response prediction
FM 0.0817 0.0325 0.0412 0.0961 0.0098 context-aware Factorization Machines
BERT4Rec 0.0685 0.0303 0.0391 0.0685 0.0069 sequential BERT4Rec: Sequential Recommendation with Bidirectional Encoder Representations from Transformer
xDeepFM 0.0743 0.0281 0.0371 0.0858 0.0089 context-aware xDeepFM: Combining Explicit and Implicit Feature Interactions for Recommender Systems
NFM 0.0736 0.0288 0.0369 0.0867 0.0088 context-aware Neural Factorization Machines for Sparse Predictive Analytics
AutoInt 0.0741 0.0275 0.0362 0.0872 0.0089 context-aware AutoInt: Automatic Feature Interaction Learning via Self-Attentive Neural Networks
AFM 0.0718 0.0284 0.0361 0.0855 0.0086 context-aware Attentional Factorization Machines: Learning the Weight of Feature Interactions via Attention Networks
FNN 0.0703 0.0274 0.0349 0.0823 0.0083 context-aware Deep Learning over Multi-field Categorical Data
GRU4Rec 0.0682 0.0247 0.0348 0.0682 0.0068 sequential Improved Recurrent Neural Networks for Session-based Recommendations
SpectralCF 0.0745 0.0238 0.0343 0.0876 0.0089 general Spectral collaborative filtering
WideDeep 0.0704 0.0261 0.0342 0.0837 0.0085 context-aware Wide & Deep Learning for Recommender Systems
GCMC 0.0765 0.0229 0.0341 0.0891 0.009 general Graph Convolutional Matrix Completion
DMF 0.0633 0.0276 0.034 0.0767 0.0078 general Deep Matrix Factorization Models for Recommender Systems
FwFM 0.0703 0.0217 0.0315 0.0823 0.0084 context-aware Field-weighted Factorization Machines for Click-Through Rate Prediction in Display Advertising
STAMP 0.0607 0.0208 0.03 0.0607 0.0061 sequential STAMP: Short-Term Attention/Memory Priority Model for Session-based Recommendation
DSSM 0.0582 0.0217 0.0287 0.0693 0.007 context-aware Learning deep structured semantic models for web search using clickthrough data
SLIMElastic 0.0495 0.0226 0.0263 0.0646 0.007 general SLIM: Sparse Linear Methods for Top-N Recommender Systems
LR 0.0528 0.0167 0.0231 0.064 0.0065 context-aware Predicting Clicks Estimating the Click-Through Rate for New Ads
Pop 0.0474 0.0136 0.0201 0.0564 0.0057 general なし
CDAE 0.0026 0.0007 0.001 0.0033 0.0003 general Collaborative Denoising Auto-Encoders for Top-N Recommender Systems

各モデルについて、テストデータに対する以下の指標を掲載しました。 @10 は 10個レコメンドを表出した、という意味です。

  • recall ... ユーザが実際に嗜好したアイテムのうち、レコメンドリストでどれくらいカバーできたかの割合
  • precision ... レコメンドリストにあるアイテムのうち、ユーザが嗜好したアイテム(適合アイテム)の割合
  • hits ... 正解のアイテムを一つ以上含むレコメンドリストを作成できた割合
  • mrr ... mean reciprocal rank。レコメンドリストを上位から見て、最初にヒットしたアイテムの順位を逆数にしたものをスコアとする。それを平均したもの。
  • ndcg ... DCG: アイテムをおすすめ順に並べた際の実際のスコアの合計値 を正規化(normalize)したもの

また、いくつかの古典的なモデルを太文字にしています。

  • Pop ... popularity。人気のアイテムを表出する
  • ItemKNN ... アイテム間の類似度を行動履歴から簡単な計算で定義して(コサイン類似度)、それを使って「あるユーザが過去見ていたアイテムに近いアイテムを出す」というもの。2000年代くらいから。
  • BPR ... Bayesian Personalized Ranking 2009年の手法。行列分解をベイズ的なアプローチで解いてランキングを導出する。

今回試したモデルの全てがこれら3つの手法よりも後に発表され、Deep Learningを使い倒すためにGPUを何枚も用意して実績を積んでいます。当然全てのモデルが上回ってほしいところなのですが... 2019 RecSys ベストペーパーで報告された内容とほぼ同じく、古典的な手法(ItemKNNとBPR)は相当強かったです。

さて、他にもこの表からわかることがいくつかあるのでまとめてみました。

  • general(ユーザとアイテムのアクション履歴のみ使う)なモデルに対して、context-aware(ユーザとアイテムのside infomationも使う)・sequential(どの順番で購入したかの順序情報を使う)モデルは総じて低い結果となりました(付加情報を駆使しているのに...)。
  • RecVAEが圧倒的に強かった。これはユーザとアイテムのヒストリーを行列にした上で、 Variational Auto-Encoder というニューラルネットワークで圧縮・復元の学習を行い、ユーザとアイテムのヒストリー行列を正確に復元できるように学習したモデル(+いくつか工夫あり)です。
    • わかりやすい指標である hits@10 を題材にすると、一番良かった RecVAE が 0.3(30%は正解を含んだレコメンドリストを表出できる)だったのに対して、一番下の CDAE は 0.003(0.3%しか正解を含んだレコメンドができない)というのはかなり差が大きいと感じました
    • なおこの RecVAE の数字は、非常に優秀な数字です
  • タスクやデータの難易度に依存するものの、機械学習に取り組み上でモデル変更のみで20ポイント以上指標に差が開くことをみることはあまり多くはない
  • 推薦において、ユーザとアイテムのアクション履歴から情報を引き出すというタスクが、モデルによって得意不得意がはっきり分かれているのだと思う
  • 古典手法 BPR より良かったモデルはわずか 7モデル (50弱のモデルを実験して)

さて、50弱のモデルを実験するのにかかった時間は1日でした。本来であれば作者の参照実装を見に行って、その使い方を学んで、自分の適用したいデータセットをそれに合わせた方式に前処理して、動かそうとしてみてバグにあたって... 一つのモデルを動かすのに1日かかることのほうが多いです(むしろ1日で終わらない)。

それを非常に短い時間で網羅的に実験を行うことができる環境を得られるのは非常に良いことではないでしょうか。

各レコメンドモデルの挙動の違いについて

ではこれらの結果についてもう少し踏み込んでみましょう。以下のモデルについて様々な指標を見てみます。

  • BPR ... 古典的だが優秀な手法
  • ItemKNN ... 古典的だが優秀な手法2
  • Popularity ... 古典手法
  • Item2Vec ... 商品IDを単語、同じセッションで同時に購入された商品群をcontextとみなしてword2vecを学習するモデル → 実装
  • FFM ... Field-aware Factorization Machines。 context-aware モデル
  • RecVAE ... 今回のチャンピオンモデル

推薦リストに一つでも正解が含まれていたユーザ数

hitsを見れば大体わかりますが、グラフにしてみました。

f:id:fufufukakaka:20211102110803p:plain
(テストデータ)推薦リストに一つでも正解が含まれていたユーザ数

圧倒的に RecVAE でした。ちなみに今回のテストデータは 6000ユーザくらい。2位がBPRで古典手法でした。

過去出現したアイテムを推薦して正解している割合

レコメンドにおいて、そのユーザが過去アクションしたことがあるアイテムをどう出すか、はかなり重要です。RepeatNet というリピートに着目したモデルもあるくらい。EC系のサイトでよくあることなのですが、周期的に同じものを買っている、というのがドメインにもよりますが散見されます。マートはその例にもれず、「またあれ買って食べたい」がよく起きるサービスです。ということで、これをできるだけ取りこぼさずに推薦できると非常に良いだろうと推察できます。

ここでは割合を表示します。(過去出現したアイテムを推薦して正解している数)/(推薦が成功した数)

f:id:fufufukakaka:20211102110951p:plain
過去出現したアイテムを推薦して正解している割合

ここで面白いのは、RecVAE・BPRなど上位モデルの値がほとんど同じで90%以上であることです。RecVAEはたくさん推薦を成功させていますが、過去出現したことのあるアイテムを着実に当てて正解数を伸ばしていたということですね。成績の良かったモデルは取りこぼしが少なかった、と言えるかもしれません。

過去出現していないアイテムを表出して正解しているユーザ数

今度は反対に、そのユーザが一度もアクションしたことがないアイテムを表出して、しかもそれが正解だった、という数を見てみます。レコメンドに求められている新規機会創出という役割をまさに表している性能値だとも言えます。

割合にするとさっきと逆のグラフになるので、ここでは絶対数を見てみます。

f:id:fufufukakaka:20211102111012p:plain
過去出現していないアイテムを表出して正解しているユーザ数

200程度、と大分規模は小さくなりましたが相変わらず RecVAE は上位にいます。リピートも見逃さないし、いきなり今まで買ったことがないアイテムを買った、という人に対しても他のモデルよりは良い精度を出せています。

対してBPRはRecVAEの半分程度となっており、ここで差が開いたように思えます。

また、Item2vec は先程のリピートアイテムで推薦を成功した割合を見ると90%以上となっていました。ここでのグラフの数値を見る限り、ほとんどがリピートアイテムを当てることに特化していたようです。

レコメンドのバリエーション

次に、各レコメンドのバリエーション(coverage)を見てみます。バリエーションというのは、全アイテムを分母として、そのモデルが推薦したアイテムのユニーク数を分子とした時の値を指しています。要するに、同じ人気のアイテムばかり推薦していたら低くなります。

f:id:fufufukakaka:20211102111045p:plain
レコメンドモデルが表出するアイテムのバリエーション

  • popularityが一番低いのは、毎回同じアイテムしか出さないため

  • FFM(context-aware)が低い。point-wiseな推定をするモデルであるためだと思われる

    • point-wise ... Factorization Machine系は「こういう特徴を持っているユーザはこういう特徴を持っているアイテムを買うかどうか」という0,1の学習を行い、ユーザごとのアイテム購入確率を出します。その確率をソートしてレコメンドリストを生成するのですが、確率を点推定しているだけなので、順序関係などは全く気にしません。その結果、人気のアイテムの購入確率が高まりそればっかり出てくる、ということがよくあります。
  • 一番カバレッジが高いのは ItemKNN、ついで Item2Vec・RecVAE と続きます
    • ItemKNN ・Item2vec などアイテムの類似度を利用するモデルがいずれもバリエーション豊かな推薦を行う傾向にありました
    • Deep Learning を利用するモデルは学習設定を正しくしないと over fit により出力が偏ってしまうイメージがあったのですが、RecVAE が予想に反しており驚きました

まとめ

以上、RecBole を使ってクックパッドマートでのユーザに対するアイテムレコメンドを行う設定で、内部実験を行った結果をご紹介いたしました。多種多様なレコメンドモデルを比較検討する上で非常に良い選択肢ではないかと思います。開発したレコメンドモデルに対する有用なベンチマークとなるのではないでしょうか。

今後レコメンドが必要になった際にどんなモデルを実装すればよいのかについて、今回の結果を参考にしていきたいと思います。

最後に、クックパッドでは、サービス開発や基盤開発にチャレンジする就業型インターン・そして新卒採用・中途採用を通年で受付けております。気になった方は是非ウェブサイトよりご応募ください。

UINavigationControllerをカスタマイズ 〜OSの影響を受けづらいカスタムナビゲーションの実装〜

こんにちは、モバイル基盤部のヴァンサン(@vincentisambart)です。

iOS 15とXcode 13がリリースされました。最新のiOS SDKでビルドしてみたら、カスタマイズされたナビゲーションバーに修正が必要だったアプリが少なくなかったようです。しかし、iOS版のクックパッドアプリでは大きくカスタマイズされているナビゲーションバーを使ってはいるものの、iOS 15に合わせてナビゲーションバーに手を入れる必要は特になかったです。

iOS版のクックパッドアプリは最近様々な形のナビゲーションバーを使っています。例えばおすすめタブはスクロールするとナビゲーションバーの高さが変わります。

f:id:vincentisambart:20211027155756p:plain:w100 f:id:vincentisambart:20211027155853p:plain:w100 f:id:vincentisambart:20211027160039p:plain:w100

また、さがすタブは画面によってナビゲーションバーの中身や高さが違いますし、レシピ詳細ではスクロールするとレシピ名がナビゲーションバーに入ります。

f:id:vincentisambart:20211027160107p:plain:w100 f:id:vincentisambart:20211027160124p:plain:w100 f:id:vincentisambart:20211027160140p:plain:w100 f:id:vincentisambart:20211027160153p:plain:w100

なぜiOS版のクックパッドアプリには修正が必要なかったのでしょうか。 この記事では、OSの変更の影響をあまり受けない大きくカスタマイズされたナビゲーションバーをiOS版のクックパッドアプリでどうやって実装したのか説明しようと思います。でもその前に、大切な注意事項があります。

注意事項

iOSの標準のナビゲーションバーは大きくカスタマイズされるように作られていません。Appleが用意した限られた設定以上にカスタマイズしようとすると、OSが更新されるたびに壊れやすいです。

正直にいうと、ナビゲーションバーのカスタマイズをおすすめできません。この記事で紹介している仕組みは壊れるリスクが低いと思いますが、今後どうなるのか分かりません。

iOSクックパッドのナビゲーションバーの歴史

iOSクックパッドでいまのナビゲーションバーの実装に至るまで、仕組みが何回か変わりました。

最初の仕組み

僕が2014年に入社した時には、カスタムなナビゲーションバーが既に実装されていました。カスタマイズされていたのは見た目とサブビューの配置でした。なぜ配置のカスタマイズが必要かと言いますと、iOS標準のUINavigationBarの真ん中にtitleViewを入れるとき、そのtitleViewがあまり大きくなりません。なので、真ん中に大きい検索ボックスを入れたければ、カスタマイズする必要です。

どうやって実装されていたと言いますと、ナビゲーションバーのボタンの作成をシステムに任せるけど、layoutSubviewsでシステムの決めたボタンの配置を変えていました。

改修

上記の仕組みはOSの更新で調整が定期的に必要でした。Xcode 9 (2017)のiOS 11 SDKでアプリをビルドした時、ナビゲーションバーがまた壊れて、もう少し壊れにくい仕組みを実装できないのか挑戦してみました。

新しい仕組みでは、システムの作成したボタンは今回触れないで、その上に載せたビューで隠して、自分の作成したボタンをさらに上に載せていました。OSの扱っているtitleViewも触れたくないので、UINavigationItem.titleViewを使っていた画面に少し不自然なワークアラウンドが必要でしたが、結果的に狙い通り以前の仕組みより頑丈でした。

最新の仕組み

2019年の上半期のデザイン案に半透明なナビゲーションバーを導入したい要望が現れました。以前の仕組みでは、システムのものの上にビューやボタンを載せているので、それを透過させると、システムのものが見えてしまいます。システムのものをいじって完全に透過させたら実装できたかもしれませんが、最初の仕組みのようなもっと壊れやすい状態に戻ってしまいそうでした。

少し前から考えていたアイデアを試すきっかけに見えました。どういうアイデアかといいますと、UINavigationControllerは使うけどUINavigationBarは使わないことです😁。実装は試行錯誤で何週間も掛かりましたが、いま使われている仕組みができました。

なぜUINavigationControllerは使うのか

UINavigationControllerは使うけどUINavigationBarは使わない」といったうちのなぜUINavigationControllerを使うのか、という部分を説明します。

UINavigationControllerを使わないで、ゼロからナビゲーションコントローラーを独自実装した方が壊れにくいのではないでしょうか。ゼロから作って挙動をシステム標準に合わせるのがとても大変ではありますし、その上でOS標準のビューコントローラーにはAppleしか実装できないところがあります。

分かりやすいところでいうと、UIViewController.navigationControllerはシステムが提供しているものです。どうしてもというなら一応swizzlingを使って挙動を変えることはできるかもしれないけど、色々壊れるリスクがあるし、戻り値はUINavigationControllerでなければいけません。代わりに自分でUIViewController.myCustomNavigationControllerのような似たメソッドを用意できるけど、既存のコードを変えなければいけません。

その他に、UINavigationControllerと全く同じ標準のアニメーションや遷移中のシャドーを再実装するのも大変そうでした。アニメーションはできるだけシステムに任せたいです。

なぜUINavigationBarは使わないのか

UINavigationBarを自由にカスタマイズできないなら、使わなければ良いだけです。UINavigationControllersetNavigationBarHidden(_:animated:)がまさにそのためにあります。

UINavigationBarを使わないといっても、多くの画面でナビゲーションバーが表示されてほしいので、ナビゲーションバー相当の機能を普通のUIViewControllerにやらせます。そのナビゲーションバー相当ののビューコントローラーをナビゲーションコントローラーの中に表示したいので、ナビゲーションスタックには画面本来のビューコントローラーが直接入るのではなく、ナビゲーションバー相当のビューコントローラーと、画面本来のビューコントローラー両方ともを管理するラッパービューコントローラーが入ります。

画面3つがプッシュされてあるナビゲーションコントローラーの親子関係は以下のようなイメージです。

NoBarNavigationController
 |
 +- FixedHeightToolbarProvidingContainerViewController
 |   +- EmbeddedNavigationToolbarViewController
 |   +- ScreenViewController1
 |
 +- FixedHeightToolbarProvidingContainerViewController
 |   +- EmbeddedNavigationToolbarViewController
 |   +- ScreenViewController2
 |
 +- FixedHeightToolbarProvidingContainerViewController
     +- EmbeddedNavigationToolbarViewController
     +- ScreenViewController3

既存のコードをあまり変えたくないし、ビューコントローラーをプッシュするたびに手動でFixedHeightToolbarProvidingContainerViewControllerにラップする必要があったら面倒なので、ラップは自動的にやる仕組みが必要です。

では実装に入りましょう。量が多いので、クラスと機能で以下のように分けました。

  • ナビゲーションバーを使わないナビゲーションコントローラーNoBarNavigationController
    • NoBarNavigationController.init
    • NoBarNavigationController.viewDidLoad
      • NoBarNavigationControllerが継承しているUINavigationControllerの本来のdelegateの扱いと経緯
      • NoBarNavigationControllerが継承しているUINavigationControllerの本来のナビゲーションバーを隠すisNavigationBarHidden
      • スワイプで戻るジェスチャーを扱うinteractivePopGestureRecognizer
    • ナビゲーションコントローラーにプッシュされるビューコントローラーを自動的にコンテナーにラップする仕組み
      • ラップを希望しないと示すプロトコルAdditionalToolbarNotNeeded
      • どうラップされたいのか明記できるプロトコルAdditionalToolbarNeeded
      • AdditionalToolbarNotNeededにもAdditionalToolbarNeededにも準拠していない場合
    • UINavigationControllerDelegateの準拠の詳細
  • ナビゲーションコントローラーにプッシュされるビューコントローラーをラップして、ツールバーをその上に入れくれるコンテナーFixedHeightToolbarProvidingContainerViewController
  • ツールバー自体の表示
    • ツールバーを管理しているビューコントローラーEmbeddedNavigationToolbarViewController
    • EmbeddedNavigationToolbarViewControllerのビューEmbeddedNavigationToolbar

ナビゲーションコントローラー

最初に見るのは肝心のナビゲーションコントローラー自体です。

注意:ここで紹介する実装は最初からこうできたわけではなく、ここに辿り着くには試行錯誤で時間かかりましたし、使ってみたら見つけた細かい問題の修正も入っています。

NoBarNavigationController.init

まずは、initの定義に不自然なところがあまりないと思います。気になるであろうwrapIfNeeded()は後ほどで説明します。init?(coder:)は需要が特になかったので実装されていません。

public final class NoBarNavigationController: UINavigationController {
    override public init(rootViewController: UIViewController) {
        let wrappedRootViewController = Self.wrapIfNeeded(rootViewController)
        super.init(nibName: nil, bundle: nil)
        viewControllers = [wrappedRootViewController]
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

NoBarNavigationController.viewDidLoad

もっと興味深いところ、viewDidLoadの実装を見てみましょう

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        // このナビゲーションコントローラーが自分のナビゲーションバーをもっていません。
        // 必要であれば、プッシュされるビューコントローラーが別のビューコントローラーにラップされて、
        // その別のビューコントローラーがナビゲーションバー相当の機能を提供してくれます。
        isNavigationBarHidden = true

        interactivePopGestureHandler = InteractivePopGestureHandler(controller: self)

        if let interactivePopGestureRecognizer = self.interactivePopGestureRecognizer {
            // 戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、
            // `UINavigationController`が自分の`interactivePopGestureRecognizer`を無効にしています。
            // 改めて有効にするために、自作の`delegate`を代入します。
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

コードが長いわけでもないのですが、だいぶ複雑なので、細かく見てみましょう。

delegate

    override public func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

まず自分を自分のdelegateにしています。delegatenavigationController(_:willShow:animated:)のタイミングでやりたい処理があるので、こうするしかありませんでした。delegateのメソッドでやることはあとで説明します。

delegateを自分で使っているけど、アプリが別の用途でdelegateを使いたい時もあるので、delegateが間違って上書きされないようにassertを入れておきましたし、別のdelegate(additionalDelegate)を設定できるようにしました。

    public weak var additionalDelegate: NoBarNavigationControllerDelegate?
    override public var delegate: UINavigationControllerDelegate? {
        didSet {
            assert(delegate === self, "delegateが必要であれば、additionalDelegateをご利用ください")
        }
    }

additionalDelegateの使っているNoBarNavigationControllerDelegateにはこのナビゲーションコントローラーが対応しているUINavigationControllerDelegateからとったメソッドが入っているだけです。

public protocol NoBarNavigationControllerDelegate: AnyObject {
    func noBarNavigationController(_ navigationController: NoBarNavigationController, willShow viewController: UIViewController, animated: Bool)
    func noBarNavigationController(_ navigationController: NoBarNavigationController, didShow viewController: UIViewController, animated: Bool)
}

viewDidLoad()の中で、delegate代入の次はナビゲーションバーを隠します。

isNavigationBarHidden

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        // (中略)
        isNavigationBarHidden = true

UINavigationBarを使わないと既に説明したので、isNavigationBarHidden = trueは自然だと思います。ただし、isNavigationBarHiddenが何かの理由でfalseに戻されたら、変な表示になりそうですね。もともと間違った変更を防ぐためにassert()を入れてありましたが、SwiftUIのビューが入ったUIHostingControllerをプッシュしてみたら、そのassert()が引っかかっていました。SwiftUIで明示的にナビゲーションバーを隠すようにしても、SwiftUIが一瞬表示したがっているので、強引ではありますが、有効にできないようにするしかありませんでした。

    override public func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
        if hidden {
            super.setNavigationBarHidden(hidden, animated: animated)
        }
    }

var isNavigationBarHidden: Boolは裏でsetNavigationBarHidden(newValue, animated: false)を読んでいるだけみたいなので、overridesetNavigationBarHidden(_:animated:)だけで良さそうです。

viewDidLoad()の中で、ナビゲーションバーを隠したあとにinteractivePopGestureRecognizerに手をつけます。

interactivePopGestureRecognizer

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        // (中略)
        interactivePopGestureHandler = InteractivePopGestureHandler(controller: self)

        if let interactivePopGestureRecognizer = self.interactivePopGestureRecognizer {
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

ここのinteractivePopGestureRecognizerの扱いがUINavigationControllerの細かい挙動に依存していて、この実装の一番壊れやすい部分の気がします。とはいえ、試してみたどのiOSバージョンでも問題なさそうでした。

UINavigationControllerは戻るボタンが隠れている場合(ナビゲーションバーが隠れている場合も含む)、自分のinteractivePopGestureRecognizerを無効にしています。このinteractivePopGestureRecognizerがスワイプで前の画面に戻る動作を扱うUIGestureRecognizerです。

ナビゲーションバーが隠れて、無効になったinteractivePopGestureRecognizerdelegateを自分で設定すると、改めて有効になります。

UIGestureRecognizerDelegateの準拠はNoBarNavigationController自身ではなく、別のクラスにしたのは、UINavigationControllerがやっていることとぶつかるリスクを最低限にしたかったからです。この準拠を見てみましょう。

private final class InteractivePopGestureHandler: NSObject, UIGestureRecognizerDelegate {
    // 循環参照を避けるために`weak`
    weak var navigationController: UINavigationController!

    init(controller: UINavigationController) {
        navigationController = controller
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return navigationController.viewControllers.count > 1
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // 表示されているビューコントローラーにスクロールビューが入っているとき、
        // スワイプで前の画面に戻ろうとしていると同時に指を上下に動かすと、スクロールビューも上下にスクロールしないために
        return false
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // ウェブビューが最初の読み込み中、そうしないと読み込みが終わるまでスワイプで前の画面に戻れません
        return otherGestureRecognizer is UIPanGestureRecognizer
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        return true
    }
}

このinteractivePopGestureRecognizerの扱いが壊れやすいなら、なぜ自分で作成した新しいジェスチャーレコグナイザーを使わなかったのでしょうか。残念ながら、そうしようとすると、複雑そうな遷移のアニメーションの扱い全部を再実装しなければいけません。難易度と大変さがグンと上がります。その上で、調べてみた時、子ビューコントローラー間の遷移のアニメーションに関するドキュメントが少なくて、本当に自分で完全に実装できるか疑問だった部分もありました。既存のinteractivePopGestureRecognizerを使った方が良いという結論に至りました。

ラップ

ナビゲーションコントローラー自体に戻って、NoBarNavigationController.initの話をした時に飛ばしたナビゲーションスタックに入るビューコントローラーのラップの仕組みの話をしましょう。

initに渡されたビューコントローラーはSelf.wrapIfNeeded(_:)を使ってラップしていましたが、他の方法で挿入されるビューコントローラーもラップされます。

    override public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
        let wrappedViewControllers = viewControllers.map { Self.wrapIfNeeded($0) }
        super.setViewControllers(wrappedViewControllers, animated: animated)
    }

    override public func pushViewController(_ viewController: UIViewController, animated: Bool) {
        let wrappedViewController = Self.wrapIfNeeded(viewController)
        super.pushViewController(wrappedViewController, animated: animated)
    }

関心の処理をしているwrapIfNeeded(_:)を見てみましょう。気をつけるべき点はwrapIfNeeded(wrapIfNeeded(viewController))wrapIfNeeded(viewController)と同じ値を返すべきところです。そうでないと、viewControllers配列に変更を加えるとき、変えていないビューが二重にラップされる可能性が出てきます。

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            assert(!(originalViewController is AdditionalToolbarNeeded), "AdditionalToolbarNeededとAdditionalToolbarNotNeeded両方に準拠していて矛盾がある")
            // ラップする必要がありません
            viewController = originalViewController
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()
        } else {
            // ビューコントローラーに特別な指定がないので、ツールバーをつけておきます
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded: originalViewController,
                toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: originalViewController)
            )
        }

        // wrapIfNeeded(wrapIfNeeded(viewController)) == wrapIfNeeded(viewController)を保証
        assert(viewController is AdditionalToolbarNotNeeded, "戻り値がAdditionalToolbarNotNeededに準拠していないとwrapIfNeeded(wrapIfNeeded(viewController))で二重ラップが起きる恐れがある")
        return viewController
    }

まだ話していないプロトコルが2つ登場しています:AdditionalToolbarNotNeededAdditionalToolbarNeeded。ここで「ツールバー」はナビゲーションバー相当のものです。命名は「NavigationBar」ではなく「Toolbar」にしたのは本物のナビゲーションバー(UINavigationBar)と区別をつけるためです。

AdditionalToolbarNotNeeded

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        // (中略)
        if originalViewController is AdditionalToolbarNotNeeded {

ビューコントローラーがAdditionalToolbarNotNeededに準拠していると、ツールバーをつけるべきではないと意味します。画面全体で表示したいビューコントローラーでも使えますが、一番のユースケースはツールバーをつけてくれるラッパービューコントローラーです。そのラッパービューコントローラーはAdditionalToolbarNotNeededに準拠することで二重ラップされるのを防ぎます。

定義がとてもシンプルで、メソッドがありません。

public protocol AdditionalToolbarNotNeeded: UIViewController {}

AdditionalToolbarNeeded

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            // (中略)
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()

AdditionalToolbarNeededはどのビューコントローラーにラップされてほしいのか明示的に指定するためのプロトコルです。メソッドはwrapInContainer()1つだけです。wrapInContainer()の中で自分をラップしているラッパービューコントローラーを作成して返すだけです。メソッドが1つだけだけど、その戻り値にまだ登場していなかったプロトコルも使われています。

public protocol AdditionalToolbarNeeded: UIViewController {
    func wrapInContainer() -> AdditionalToolbarProvidingContainer
}

public protocol AdditionalToolbarProvidingContainer: AdditionalToolbarNotNeeded {
    var providedToolbarViewController: UIViewController { get }
    var embeddedViewController: UIViewController { get }
}

AdditionalToolbarProvidingContainerAdditionalToolbarNotNeededを必要にしているのはAdditionalToolbarNotNeededの話をした時に話した通り二重ラップを防ぐためです。

AdditionalToolbarProvidingContainerにあるprovidedToolbarViewControllerembeddedViewControllerはラッパー(別名コンテナー)に入った2つのビューコントローラーを直接取り出すためです:providedToolbarViewControllerはツールバーを表示してくれるビューコントローラーであって、embeddedViewControllerはラップされている画面の本来のビューコントローラーです。

AdditionalToolbarNotNeededにもAdditionalToolbarNeededにも準拠していない場合

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            // (中略)
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            // (中略)
        } else {
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded: originalViewController,
                toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: originalViewController)
            )
        }

ラップされるビューコントローラーがAdditionalToolbarNeededにもAdditionalToolbarNotNeededにも準拠していないときに使われるFixedHeightToolbarProvidingContainerViewControllerはもちろんAdditionalToolbarProvidingContainerに準拠しています。

AdditionalToolbarNeededにもAdditionalToolbarNotNeededにも準拠していないのは以下のようにAdditionalToolbarNeededに準拠している場合と同じ挙動にです。

extension MyScreenViewController: AdditionalToolbarNeeded {
    func wrapInContainer() -> AdditionalToolbarProvidingContainer {
        return FixedHeightToolbarProvidingContainerViewController(
            embedded: self,
            toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: self)
        )
    }
}

UINavigationControllerDelegate

ナビゲーションコントローラーに関してまだ残っているのはあとUINavigationControllerDelegateの準拠だけです。

extension NoBarNavigationController: UINavigationControllerDelegate {
    public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)
        additionalDelegate?.noBarNavigationController(self, didShow: viewController, animated: animated)
    }

navigationController(_:didShow:animated:)additionalDelegateの同じメソッドを呼んでいるだけです。

    public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)

        // 遷移の前後が`FixedHeightToolbarProvidingContainerViewController`のインスタンスの場合のみ、カスタムなトランジションを使います
        if animated,
           let transitionCoordinator = self.transitionCoordinator,
           let source = transitionCoordinator.viewController(forKey: .from) as? FixedHeightToolbarProvidingContainerViewController,
           let destination = transitionCoordinator.viewController(forKey: .to) as? FixedHeightToolbarProvidingContainerViewController {
            FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
                from: source,
                to: destination,
                inside: self,
                coordinatedBy: transitionCoordinator
            )
        }

        additionalDelegate?.noBarNavigationController(self, willShow: viewController, animated: animated)
    }
}

navigationController(_:willShow:animated:)も匹敵するadditionalDelegateのメソッドを呼んでいますが、その前に遷移がFixedHeightToolbarProvidingContainerViewControllerからFixedHeightToolbarProvidingContainerViewControllerへの場合のみ、特別なアニメーションの準備をします。

iPhoneを手にとってください。Apple標準のアプリ(例えば設定アプリ)でも、サードパーティーのいくつかのアプリでも、ナビゲーションコントローラーで遊んでみてください。アニメーションに気をつけながら、ビューコントローラーをプッシュして、スワイプで前の画面をゆっくり戻って、改めてプッシュして、を繰り返してみましょう。よく見ると画面間のトランジションが意外と複雑です。設定アプリのトップ画面のようにナビゲーションバーのタイトルが画面のビューコントローラー自体に溶け込んでいる場合は普通のナビゲーションバーとまた少し違います。ナビゲーションバーをカスタマイズしている一部の第三者アプリでトランジションがスムーズでない時もあります。

この記事のようなナビゲーションバーのないナビゲーションコントローラーの場合、特別なことをしない限り、ツールバーがシステムにとって表示されているビューコントローラーの一部でしかないので、トランジションはビューコントローラー全体が滑り込むだけです。そこまで悪くもないのですが、もう少しこだわれると思います。標準のナビゲーションバーのトランジションが複雑なので、結局iOSクックパッドではフェードイン・フェードアウトだけにしました。ナビゲーションバーの高さが変わる場合がさらに複雑なので標準の全体滑り込むアニメーションだけになります。詳細は後ほどFixedHeightToolbarProvidingContainerViewControllerの話をする時にしましょう。

NoBarNavigationControllerはこれですべてのコードに目を通したので、次はデフォルトで使われるラッパー/コンテナーを見ようと思います。

FixedHeightToolbarProvidingContainerViewController

FixedHeightToolbarProvidingContainerViewControllerはデフォルトで使われるコンテナーです。ラップされているビューコントローラーとその上のツールバーの表示・管理をしているビューコントローラーを束ねているだけです。FixedHeightToolbar命名の通りツールバーの高さが作成時に決まって、その後に変わることがありません。透過のツールバーも対応されていません。iOSクックパッドでは透過しているツールバーは別のコンテナーが使われますが、基礎は同じです。

構成は本当にシンプルです。

public final class FixedHeightToolbarProvidingContainerViewController: UIViewController {
    private let embedded: UIViewController
    private let toolbarViewController: FixedHeightToolbarViewController

    public init(
        embedded: UIViewController,
        toolbarViewController: FixedHeightToolbarViewController
    ) {
        self.embedded = embedded
        self.toolbarViewController = toolbarViewController

        super.init(nibName: nil, bundle: nil)

        addChild(toolbarViewController)
        toolbarViewController.didMove(toParent: self)

        addChild(embedded)
        embedded.didMove(toParent: self)
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

toolbarViewControllerの定義をよく見ると、FixedHeightToolbarViewControllerというプロトコルが使われているのですが、このプロトコルには複雑なところが特にないと思います(命名が似ていて少し分かりにくいかもしれませんが、このツールバービューコントローラーのプロトコル名はコンテナーのクラス名からProvidingContainerを外したものです)

public protocol FixedHeightToolbarViewController: UIViewController {
    // このビューコントローラーが表示されている間にナビゲーションコントローラーの`popViewController()`を呼べるかどうか
    // (基本的に戻るボタンを表示すべきか)
    var canPop: Bool { get set }
    // ツールバーの高さ(決まってから変わるべきでない)
    var toolbarHeight: CGFloat { get }
    // ツールバーの背景色
    var toolbarBackgroundColor: UIColor { get }
}

コンテナーはもちろんAdditionalToolbarNeededの話をした時に説明したAdditionalToolbarProvidingContainerには準拠しています。

extension FixedHeightToolbarProvidingContainerViewController: AdditionalToolbarProvidingContainer {
    public var providedToolbarViewController: UIViewController { return toolbarViewController }
    public var embeddedViewController: UIViewController { return embedded }
}

戻るボタンを表示すべきかどうかはツールバーのビューコントローラーが自分で判断するのが難しいので、コンテナーがviewWillAppearviewDidAppearのタイミングで伝えます。

    private var canPop: Bool {
        // 自分がナビゲーションスタックの一番最初のビューコントローラーの場合だけポップできません
        return navigationController?.viewControllers.first != self
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 表示される度に`toolbarViewController.canPop`を更新します。
        // 以前表示されてからナビゲーションスタックが変わった可能性があります。
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    override public func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // `toolbarViewController.canPop`の更新は`viewWillAppear`のタイミングだけで良さそうに感じるかもしれないが、
        // 色々試したら`viewWillAppear`のタイミングで`navigationController?.viewControllers`が最新状態になっていないこともあったので、
        // 念のために`viewDidAppear`でもやります
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

ビューの配置も複雑なことが特にありません。

    override public func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = toolbarViewController.toolbarBackgroundColor

        embedded.view.translatesAutoresizingMaskIntoConstraints = false
        // ツールバーより後ろになるために`embedded`の`view`を最初に追加します
        // (自分の`bounds`を超えるやんちゃなビューコントローラーがいる)
        view.addSubview(embedded.view)
        embedded.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        embedded.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        embedded.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        // ツールバーはセーフエリア内にとどまるので、その外でツールバーの背景色を出すのは`toolbarBackgroundView`
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarBackgroundView)
        toolbarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        toolbarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        toolbarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        toolbarBackgroundView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: toolbarViewController.toolbarHeight).isActive = true
        embedded.view.topAnchor.constraint(equalTo: toolbarBackgroundView.bottomAnchor).isActive = true

        toolbarViewController.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarViewController.view)
        toolbarViewController.view.heightAnchor.constraint(equalToConstant: toolbarViewController.toolbarHeight).isActive = true
        toolbarViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        toolbarViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        toolbarViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    }

FixedHeightToolbarProvidingContainerViewControllerはあとトランジションの話に出ていたanimateAlongsideTransitionだけです。以前説明した通り、ツールバーの部分だけ、フェードイン・フェードアウトをします。システムが既にやっているトランジションとぶつかりたくないので、既存のビューをできるだけいじらないで、代わりにスナップショットを撮って、独自アニメーションはスナップショットだけを使います。少し心配だった部分あったのでassertを多めです。

    static func animateAlongsideTransition(
        from source: FixedHeightToolbarProvidingContainerViewController,
        to destination: FixedHeightToolbarProvidingContainerViewController,
        inside navigationController: NoBarNavigationController,
        coordinatedBy coordinator: UIViewControllerTransitionCoordinator
    ) {
        // 高さが違っていれば、標準のアニメーションだけにします
        if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
            return
        }

        destination.loadViewIfNeeded()

        guard let sourceSnapshot = source.toolbarViewController.view.snapshotView(afterScreenUpdates: false) else { return }
        let destinationSnapshot: UIView?
        // `destination`のビューがまだ表示されていなくて、ビューのヒエラルキーに入っていないはず
        if destination.view.superview == nil {
            // `destination`の親が`navigationController`でなければ、`destination.view`を`navigationController.view`に追加したらクラッシュしてしまいます
            // (子ビューコントローラーのビューがその親ビューコントローラーのビューのサブビューであるべきなので)
            assert(destination.parent == navigationController, "予期しない状態")
            // `destination.view`がビューのヒエラルキーに入っていないとスナップショットを撮れないので、一時的に`navigationController.view`に追加します
            navigationController.view.addSubview(destination.view)
            destination.view.layoutIfNeeded()
            // `destination.toolbarViewController`がまだ表示されていないので、`afterScreenUpdates`を`true`にしないとスナップショットが撮れません
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: true)
            // `destination`を元の状態に戻します
            destination.view.removeFromSuperview()
        } else {
            assertionFailure("予期しない状態")
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: false)
        }

        // アニメーションの最初の状態
        // `toolbarBackgroundView`が本当のツールバーを隠してくれます
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = source.toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.frame = CGRect(
            x: 0,
            y: 0,
            width: source.toolbarViewController.view.bounds.width,
            height: source.toolbarViewController.view.frame.maxY
        )
        coordinator.containerView.addSubview(toolbarBackgroundView)

        sourceSnapshot.frame = source.toolbarViewController.view.frame
        toolbarBackgroundView.addSubview(sourceSnapshot)
        sourceSnapshot.alpha = 1

        if let destinationSnapshot = destinationSnapshot {
            destinationSnapshot.frame = destination.toolbarViewController.view.frame
            toolbarBackgroundView.addSubview(destinationSnapshot)
            destinationSnapshot.alpha = 0
        } else {
            assertionFailure("予期しない状態")
        }

        coordinator.animate(alongsideTransition: { context in
            context.containerView.bringSubviewToFront(toolbarBackgroundView)

            // アニメーションの最後の状態
            destinationSnapshot?.alpha = 1
            sourceSnapshot.alpha = 0
            toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
        }, completion: { _ in
            // アニメーションのために追加していた`toolbarBackgroundView`とそのサブビューであるスナップショットを外します
            toolbarBackgroundView.removeFromSuperview()
        })
    }

ツールバー

あと残るのはツールバー自体だけです。iOSクックパッドは本来多くの画面で使われるツールバーに機能が豊富です。真ん中に表示されるのは画面によってタイトルだけ、タイトルとサブタイトル、検索ボックス。検索ボックスをタップすると表示させる検索ビューコントローラーの扱いもツールバーのビューコントローラーに入っています。この記事が既に複雑で長いので、ここでシンプルなタイトルを表示するだけにしようと思います。

f:id:vincentisambart:20211027160223p:plain:w300 f:id:vincentisambart:20211027160305p:plain:w300

ツールバーといっても、ビューコントローラー(EmbeddedNavigationToolbarViewController)とビュー(EmbeddedNavigationToolbar)に分かれています。

EmbeddedNavigationToolbarViewController

EmbeddedNavigationToolbarViewControllerは単にEmbeddedNavigationToolbarを表示して、FixedHeightToolbarProvidingContainerViewControllerEmbeddedNavigationToolbarの仲介をしているだけです。

final class EmbeddedNavigationToolbarViewController: UIViewController, FixedHeightToolbarViewController {
    private let viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        let navigationToolbar = EmbeddedNavigationToolbar(
            viewController: viewController,
            canPop: canPop
        )
        navigationToolbar.delegate = self
        view = navigationToolbar
    }

    private var toolbar: EmbeddedNavigationToolbar {
        guard let toolbar = view as? EmbeddedNavigationToolbar else {
            fatalError("ビューがEmbeddedNavigationToolbarのインスタンスのはず")
        }
        return toolbar
    }

    // MARK: FixedHeightToolbarViewController

    var canPop: Bool = false {
        didSet {
            if isViewLoaded {
                toolbar.canPop = canPop
            }
        }
    }

    let toolbarHeight = EmbeddedNavigationToolbar.height
    let toolbarBackgroundColor = EmbeddedNavigationToolbar.backgroundColor
}

やっている一番ビューコントローラーらしいことは戻るボタンのタップの扱いかもしれません。

extension EmbeddedNavigationToolbarViewController: EmbeddedNavigationToolbarDelegate {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton) {
        navigationController?.popViewController(animated: true)
    }
}

EmbeddedNavigationToolbar

あとはEmbeddedNavigationToolbarViewControllerviewであるEmbeddedNavigationToolbarだけです。EmbeddedNavigationToolbarは普通のビューですが、一番重要なのが左右のボタンと真ん中のtitleViewの扱いです。

でもサブビューの話の前には定数の定義を見ましょう。

final class EmbeddedNavigationToolbar: UIView {
    private static let horizontalMargin: CGFloat = 7.0
    private static let titleViewVerticalMargin: CGFloat = 7.0
    private static let horizontalSpacing: CGFloat = 3.0
    private static let titleViewHorizontalMargin: CGFloat = 7.0
    private static let itemsStackViewMinimumWidth: CGFloat = 2.0
    static let height: CGFloat = {
        // 実は標準のナビゲーションバーの高さはそんなにシンプルではありません。
        // 基本的にiPadでは50ptであって、iPhoneでは44ptですが、iPhoneのモーダルの場合は56ptのようです。
        // それをこの仕組みで実現できないか検証する予定ではありますが、まだやっていません。
        if UIDevice.current.userInterfaceIdiom == .pad {
            return 50.0
        } else {
            return 44.0
        }
    }()
    static let backgroundColor: UIColor = .lightGray

高さの問題を除いて、特に目立つことはなかったと思います。左右のボタンの配置はスタックビューを使用します。

    private let leftItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    private let rightItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

initはインスタンス変数の初期化やAuto Layoutの制約が特別なことをやっていないのですが、気になりそうなのはobserveの使い方だと思います。コメントで経緯を説明します。

    private let navigationItem: UINavigationItem
    private var titleView: UIView?
    var canPop: Bool {
        didSet {
            if canPop != oldValue {
                recreateButtons()
            }
        }
    }
    private var sideButtonItemsObservations: [NSKeyValueObservation] = []
    private var titleViewObservation: NSKeyValueObservation?
    weak var delegate: EmbeddedNavigationToolbarDelegate?

    // `viewController`がこのツールバーの下に表示されるビューコントローラーです。ツールバーに表示される情報がこのビューコントローラーが元です。
    // `canPop`は上記に説明した通り、`viewController`を自分のナビゲーションコントローラーからポップできるのか、
    // すなわちナビゲーションコントローラーのナビゲーションスタックの最初のビューコントローラーじゃないことを示します。
    init(
        viewController: UIViewController,
        canPop: Bool
    ) {
        navigationItem = viewController.navigationItem
        self.canPop = canPop

        super.init(frame: .zero)

        backgroundColor = Self.backgroundColor
        clipsToBounds = true

        leftItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(leftItemsStackView)
        leftItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        leftItemsStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalMargin).isActive = true

        rightItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(rightItemsStackView)
        rightItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        rightItemsStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalMargin).isActive = true

        // ツールバーの真ん中らに入る`titleView`の指定が変わったら、すぐ反映されるためにKVOを使います。
        // `options: .initial`が指定されているので、変更の時だけではなく、クロージャーが最初の状態でも呼ばれます。
        titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weak self, weak viewController] navigationItem, _ in
            guard let self = self, let viewController = viewController else { return }
            self.setUpTitleView(navigationItem.titleView ?? self.makeDefaultTitleView(for: viewController))
        }

        // ツールバーに影響のある`UINavigationItem`のプロパティもKVOで監視します。
        // すべてのクロージャーが同じことをやっていますが、値の型がいくつかあるので、全部を1つのクロージャーにまとめられません。
        sideButtonItemsObservations = [
            navigationItem.observe(\.leftBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.hidesBackButton) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftItemsSupplementBackButton) { [weak self] _, _ in self?.recreateButtons() },
        ]

        // 左右のボタンを最初の状態で作成しておきます。
        recreateButtons()
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // 高さが固定なので、AutoLayoutにその高さを教えておきましょう。
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: Self.height)
    }

KVOはSwift時代でユースケースが限られていますが、ここでは活用する必要があります。

delegateは戻るボタンの扱いだけに使われています。

protocol EmbeddedNavigationToolbarDelegate: AnyObject {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton)
}

その戻るボタンを表示すべきかどうかはUINavigationItemの標準の仕様に合わせています。

    private var shouldDisplayBackButton: Bool {
        if !canPop {
            return false
        }
        if navigationItem.hidesBackButton {
            return false
        }
        return (navigationItem.leftBarButtonItem == nil || navigationItem.leftItemsSupplementBackButton)
    }

その戻る含む左右のボタンの作成はrecreateButtons()が担当しています。

    private func recreateButtons() {
        // ボタンを作り直すので、以前のボタンをまず排除する必要があります。
        (leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }

        var leftBarButtonItems = navigationItem.leftBarButtonItems ?? []
        if shouldDisplayBackButton {
            leftBarButtonItems.insert(makeBackButtonItem(), at: 0)
        }

        Self.makeButtons(from: leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
        // `rightBarButtonItems`には一番右のボタンが最初に入っているので、スタックビューにボタンを入れる際は並び順を逆にする必要があります。
        Self.makeButtons(from: navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }

        Self.preventFromExpandingHorizontally(leftItemsStackView)
        Self.preventFromExpandingHorizontally(rightItemsStackView)
    }

recreateButtons()は長くないのですが、ツールバーの他のメソッドをいくつか呼んでいるのでそのメソッドを見てみましょう。まず本当のボタンをUIBarButtonItemから作成するmakeButtons(from:)があります。

    private static func makeButtons(from barButtonItems: [UIBarButtonItem]?) -> [NavigationToolbarButton] {
        return (barButtonItems ?? []).map { item in
            let button = NavigationToolbarButton(barButtonItem: item)
            button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
            return button
        }
    }

NavigationToolbarButtonはあとで見ましょう。setContentHuggingPriority(_:for:)はボタンが必要以上に大きくならないためです。

スタックビューが空っぽの場合、幅の定義がないのでシンプルのUIView同様制約によってどの幅にもなれます。特に真ん中のtitleViewの左右が左右のスタックビューに結びついている場合、titleViewが取ってほしいスペースを取ってくれないので、それを避けるために、スタックビューが空の場合、幅固定(2pt)のシンプルなビューを入れておきます。

    private static func preventFromExpandingHorizontally(_ stackView: UIStackView) {
        assert(stackView.axis == .horizontal, "垂直のスタックビューに対応していない")
        if !stackView.arrangedSubviews.isEmpty {
            return
        }
        let view = UIView()
        view.widthAnchor.constraint(equalToConstant: itemsStackViewMinimumWidth).isActive = true
        stackView.addArrangedSubview(view)
    }

戻るボタンは見た目が普通のleftBarButtonItemsと同じなので、直接作るのではなく、UIBarButtonItemを作って、ボタンが普通のleftBarButtonItemsと一緒に作成されるようにしました。

    private func makeBackButtonItem() -> UIBarButtonItem {
        // `UIBarButtonItem(systemItem:)`に渡される`systemItem`は作成後に知るすべがありません。
        // `NavigationToolbarButton`が`UIBarButtonItem`を元に作成される時、表示されてほしい画像を入手する必要があるので、`UIBarButtonItem(systemItem:)`を使えません。
        // 代わりにSF Symbolを活用して、シンボルから普通の画像を生成します。
        let backButtonImage = UIImage(
            systemName: "chevron.backward",
            withConfiguration: UIImage.SymbolConfiguration(pointSize: 23)
        )?.withTintColor(.orange, renderingMode: .alwaysOriginal)
        let backButtonItem = UIBarButtonItem(
            image: backButtonImage,
            style: .plain,
            target: self,
            action: #selector(didTapBackButton)
        )
        backButtonItem.accessibilityLabel = "戻る"
        return backButtonItem
    }

    @objc private func didTapBackButton(_ sender: UIButton) {
        // 自分の`delegate`を呼ぶだけです。
        delegate?.navigationToolbar(self, didTapBackButton: sender)
    }

titleViewの作成と配置はシンプルでです。余談ですが、実は、iOSクックパッドはtitleView配置にモードが2つあります(centerfill)。全体のコードが既に十分複雑なので、ここはtitleViewに画面の全ての幅を取らせるfillだけにしました。

    private func setUpTitleView(_ titleView: UIView) {
        self.titleView?.removeFromSuperview()
        self.titleView = titleView

        titleView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleView)
        titleView.topAnchor.constraint(equalTo: topAnchor, constant: Self.titleViewVerticalMargin).isActive = true
        titleView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.titleViewVerticalMargin).isActive = true
        titleView.leadingAnchor.constraint(
            equalTo: leftItemsStackView.trailingAnchor,
            constant: Self.titleViewHorizontalMargin
        ).isActive = true
        titleView.trailingAnchor.constraint(
            equalTo: rightItemsStackView.leadingAnchor,
            constant: -Self.titleViewHorizontalMargin
        ).isActive = true
    }

    private func makeDefaultTitleView(for viewController: UIViewController) -> UIView {
        let titleView = EmbeddedNavigationToolbarTitleOnlyTitleView()
        titleView.observe(viewController: viewController)
        return titleView
    }
}

デフォルトのtitleViewであるEmbeddedNavigationToolbarTitleOnlyTitleViewでやっている時別なことはviewControllernavigationItemtitleを監視しているところくらいです。

final class EmbeddedNavigationToolbarTitleOnlyTitleView: UIView {
    private let titleLabel = UILabel()

    init() {
        super.init(frame: .zero)

        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 2
        titleLabel.adjustsFontSizeToFitWidth = true
        titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    }

    private var titleObservation: NSKeyValueObservation?

    func observe(viewController: UIViewController) {
        titleObservation = viewController.navigationItem.observe(\.title, options: [.initial]) { [weak self] navigationItem, _ in
            self?.titleLabel.text = navigationItem.title
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

最後は左右のボタンに使われるNavigationToolbarButtonです。UIBarButtonItemを元に普通のボタンを作成しています。戻るボタンの話をした時も書きましたが、残念ながらUIBarButtonItem(systemItem:)で作成されたUIBarButtonItemは渡されたsystemItemをあとで分かるAPIがないので、対応できません。makeBackButtonItem()同様SF Symbolsを活用するのが一番無難かと思います。

public final class NavigationToolbarButton: UIButton {
    private static let buttonHorizontalPadding: CGFloat = 2.0
    private let barButtonItem: UIBarButtonItem
    private var enabledObservation: NSKeyValueObservation?

    public init(barButtonItem: UIBarButtonItem) {
        self.barButtonItem = barButtonItem

        super.init(frame: .zero)

        if let image = barButtonItem.image {
            setImage(image, for: .normal)
        } else if let title = barButtonItem.title {
            setTitle(title, for: .normal)
            if let tintColor = barButtonItem.tintColor {
                setTitleColor(tintColor, for: .normal)
                setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
            } else {
                setTitleColor(.black, for: .normal)
            }
            setTitleColor(.gray, for: .disabled)

            if let titleTextAttributes = barButtonItem.titleTextAttributes(for: .normal),
               let font = titleTextAttributes[.font] as? UIFont {
                titleLabel?.font = font
            } else {
                titleLabel?.font = UIFont.systemFont(ofSize: 16)
            }
        } else {
            // ここがこの仕組みの制限の1つです。
            // なぜか`UIBarButtonItem`作成時に渡された`systemItem`をあとで取り出す方法がないので、ボタンを作れません。
            // また、`UIBarButtonItem`作成時に渡された`customView`に関しては取り出せるようですが、需要がなかったのでここで対応していません。
            fatalError("このボタンアイテムの種類に対応していない:\(barButtonItem)")
        }
        contentEdgeInsets = UIEdgeInsets(
            top: 0,
            left: Self.buttonHorizontalPadding,
            bottom: 0,
            right: Self.buttonHorizontalPadding
        )
        accessibilityLabel = barButtonItem.accessibilityLabel
        if let target = barButtonItem.target, let action = barButtonItem.action {
            addTarget(target, action: action, for: .touchUpInside)
        }

        enabledObservation = barButtonItem.observe(\.isEnabled, options: [.initial]) { [weak self] item, _ in
            self?.isEnabled = item.isEnabled
        }

        sizeToFit()

        if #available(iOS 13.4, *) {
            isPointerInteractionEnabled = true
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension UIColor {
    fileprivate var rgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        var red: CGFloat = 0.0
        var green: CGFloat = 0.0
        var blue: CGFloat = 0.0
        var alpha: CGFloat = 0.0

        getRed(&red, green: &green, blue: &blue, alpha: &alpha)

        return (red, green, blue, alpha)
    }

    fileprivate var heavilyHighlighted: UIColor {
        let ratio: CGFloat = 0.85
        let (red, green, blue, alpha) = rgbaComponents

        return UIColor(
            red: red * ratio,
            green: green * ratio,
            blue: blue * ratio,
            alpha: alpha
        )
    }
}

最後に

ナビゲーションコントローラーのツールバーを自由に定義できる仕組みは結局必要だったコードの量がそれなりにありました。

iOSクックパッドはどの画面でもこのナビゲーションコントローラーを使っています。透過しているツールバーはEmbeddedNavigationToolbarViewController/EmbeddedNavigationToolbarを改造して作られています。

実装はできたけど、懸念点は少なくありません。

  • 標準のナビゲーションバーの高さがモーダルの時に変わるのは現時点で対応していません。
  • スワイプで戻れる動作が動くために、ドキュメントされていない挙動に頼っています。
  • ツールバーの左右のボタンの定義はUIBarButtonItem(systemItem:)を使えません。
  • ツールバーの遷移アニメーションが標準のと違います。
  • SwiftUIが勝手にナビゲーションバーを表示しようとしているので、それを無視していることでいずれ不具合が発生する可能性があります。

懸念点の一部はもっと頑張れば対応できると思いますが、一部はApple側で変更が必要です。最近Apple側でナビゲーションバーをもっと柔軟にカスタマイズできる動きがあるように見えないので、システムを自分のデザインに合わせるのではなく、自分のデザインをシステム標準のものに合わせた方がおすすめです。

頻繁にこんな細かいコードを書いているわけではありませんが、iOSエンジニアの仲間は募集しているので、興味ある方はぜひご応募ください https://info.cookpad.com/careers/

// This project is licensed under the MIT license.
//
// Copyright (c) 2021 Cookpad Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

import UIKit

public protocol AdditionalToolbarNotNeeded: UIViewController {}

// If a view controller does conform to neither `AdditionalToolbarNeeded` nor `AdditionalToolbarNotNeeded`,
// the behavior is the same as if it conformed to `AdditionalToolbarNeeded` and defined `wrapInContainer()` as follows:
// func wrapInContainer() -> AdditionalToolbarProvidingContainer {
//     return FixedHeightToolbarProvidingContainerViewController(
//         embedded: self,
//         toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: self)
//     )
// }
public protocol AdditionalToolbarNeeded: UIViewController {
    func wrapInContainer() -> AdditionalToolbarProvidingContainer
}

public protocol AdditionalToolbarProvidingContainer: AdditionalToolbarNotNeeded {
    var providedToolbarViewController: UIViewController { get }
    var embeddedViewController: UIViewController { get }
}

public protocol NoBarNavigationControllerDelegate: AnyObject {
    func noBarNavigationController(_ navigationController: NoBarNavigationController, willShow viewController: UIViewController, animated: Bool)
    func noBarNavigationController(_ navigationController: NoBarNavigationController, didShow viewController: UIViewController, animated: Bool)
}

public final class NoBarNavigationController: UINavigationController {
    override public init(rootViewController: UIViewController) {
        let wrappedRootViewController = Self.wrapIfNeeded(rootViewController)
        super.init(nibName: nil, bundle: nil)
        viewControllers = [wrappedRootViewController]
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private var interactivePopGestureHandler: InteractivePopGestureHandler?
    override public func viewDidLoad() {
        super.viewDidLoad()
        delegate = self

        // This navigation controller does not have its own navigation bar.
        // If needed, a view controller pushed will get wrapped by an other view controller
        // that will provide an equivalent to the navigation bar.
        isNavigationBarHidden = true

        interactivePopGestureHandler = InteractivePopGestureHandler(controller: self)

        if let interactivePopGestureRecognizer = self.interactivePopGestureRecognizer {
            // If the back button is hidden (that includes the navigation bar being hidden),
            // `UINavigationController` will disable its `interactivePopGestureRecognizer`.
            // To reenable it, we assign the recognizer's delegate to be own custom handler.
            interactivePopGestureRecognizer.delegate = interactivePopGestureHandler
        } else {
            assertionFailure("interactivePopGestureRecognizerが作成されてあると期待されています")
        }
    }

    public weak var additionalDelegate: NoBarNavigationControllerDelegate?
    override public var delegate: UINavigationControllerDelegate? {
        didSet {
            assert(delegate === self, "If you need to use a delegate, use additionalDelegate instead")
        }
    }

    // Make sure the navigation bar is always hidden.
    // (SwiftUI tends to set it to false)
    override public func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
        if hidden {
            super.setNavigationBarHidden(hidden, animated: animated)
        }
    }

    override public func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
        let wrappedViewControllers = viewControllers.map { Self.wrapIfNeeded($0) }
        super.setViewControllers(wrappedViewControllers, animated: animated)
    }

    override public func pushViewController(_ viewController: UIViewController, animated: Bool) {
        let wrappedViewController = Self.wrapIfNeeded(viewController)
        super.pushViewController(wrappedViewController, animated: animated)
    }

    private static func wrapIfNeeded(_ originalViewController: UIViewController) -> UIViewController {
        let viewController: UIViewController
        if originalViewController is AdditionalToolbarNotNeeded {
            assert(!(originalViewController is AdditionalToolbarNeeded), "A view controller cannot at the same time want a navigation controller and not want one")
            // No need to wrap.
            viewController = originalViewController
        } else if let toolbarNeedingViewController = originalViewController as? AdditionalToolbarNeeded {
            viewController = toolbarNeedingViewController.wrapInContainer()
        } else {
            // The view controller does not specify anything special, so we create a simple toolbar.
            viewController = FixedHeightToolbarProvidingContainerViewController(
                embedded: originalViewController,
                toolbarViewController: EmbeddedNavigationToolbarViewController(viewController: originalViewController)
            )
        }

        // Ensure that wrapIfNeeded(wrapIfNeeded(viewController)) == wrapIfNeeded(viewController)
        assert(viewController is AdditionalToolbarNotNeeded, "A return value not conforming to AdditionalToolbarNotNeeded risks being doubly wrapped when wrapIfNeeded is called once again on it")
        return viewController
    }
}

private final class InteractivePopGestureHandler: NSObject, UIGestureRecognizerDelegate {
    // `weak` to prevent circular references.
    weak var navigationController: UINavigationController!

    init(controller: UINavigationController) {
        navigationController = controller
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return navigationController.viewControllers.count > 1
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // When the view controller displayed contains a scroll view,
        // so that when swiping to get back to the previous view controller,
        // swiping up/down does not also scroll the scroll view.
        return false
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        // When a web view is doing its first loading, so that we can swipe back to the previous view controller.
        return otherGestureRecognizer is UIPanGestureRecognizer
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        return true
    }
}

extension NoBarNavigationController: UINavigationControllerDelegate {
    public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)
        additionalDelegate?.noBarNavigationController(self, didShow: viewController, animated: animated)
    }

    public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        assert(self == navigationController)

        // Use a custom transition only when transitioning between 2 instances of `FixedHeightToolbarProvidingContainerViewController`.
        if animated,
           let transitionCoordinator = self.transitionCoordinator,
           let source = transitionCoordinator.viewController(forKey: .from) as? FixedHeightToolbarProvidingContainerViewController,
           let destination = transitionCoordinator.viewController(forKey: .to) as? FixedHeightToolbarProvidingContainerViewController {
            FixedHeightToolbarProvidingContainerViewController.animateAlongsideTransition(
                from: source,
                to: destination,
                inside: self,
                coordinatedBy: transitionCoordinator
            )
        }

        additionalDelegate?.noBarNavigationController(self, willShow: viewController, animated: animated)
    }
}

public final class FixedHeightToolbarProvidingContainerViewController: UIViewController {
    private let embedded: UIViewController
    private let toolbarViewController: FixedHeightToolbarViewController

    public init(
        embedded: UIViewController,
        toolbarViewController: FixedHeightToolbarViewController
    ) {
        self.embedded = embedded
        self.toolbarViewController = toolbarViewController

        super.init(nibName: nil, bundle: nil)

        addChild(toolbarViewController)
        toolbarViewController.didMove(toParent: self)

        addChild(embedded)
        embedded.didMove(toParent: self)
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private var canPop: Bool {
        // Cannot pop only if you are at the start of the navigation stack.
        return navigationController?.viewControllers.first != self
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Update `toolbarViewController.canPop` every time this view appears,
        // as it's possible that the navigation stack changed since last time.
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    override public func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // It seems like updating `toolbarViewController.canPop` in `viewWillAppear` should be enough,
        // but it turns out that in some cases `navigationController?.viewControllers` is not in
        // its final state when `viewWillAppear` is called, so just in case also do it here.
        // We are not doing it only in `viewDidAppear` because you can have an incorrect appearance for a split second.
        let canPop = self.canPop
        if toolbarViewController.canPop != canPop {
            toolbarViewController.canPop = canPop
        }
    }

    override public func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = toolbarViewController.toolbarBackgroundColor

        embedded.view.translatesAutoresizingMaskIntoConstraints = false
        // Add the embedded view controller before the toolbar so that the toolbar is above.
        // (some badly behaving view controllers go beyond their bounds)
        view.addSubview(embedded.view)
        embedded.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        embedded.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        embedded.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true

        // The toolbar stays in the safe area, so create a `toolbarBackgroundView` to get our background color outside of it.
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarBackgroundView)
        toolbarBackgroundView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        toolbarBackgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        toolbarBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        toolbarBackgroundView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: toolbarViewController.toolbarHeight).isActive = true
        embedded.view.topAnchor.constraint(equalTo: toolbarBackgroundView.bottomAnchor).isActive = true

        toolbarViewController.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(toolbarViewController.view)
        toolbarViewController.view.heightAnchor.constraint(equalToConstant: toolbarViewController.toolbarHeight).isActive = true
        toolbarViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        toolbarViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        toolbarViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    }

    static func animateAlongsideTransition(
        from source: FixedHeightToolbarProvidingContainerViewController,
        to destination: FixedHeightToolbarProvidingContainerViewController,
        inside navigationController: NoBarNavigationController,
        coordinatedBy coordinator: UIViewControllerTransitionCoordinator
    ) {
        // If the height is different, use the default animation.
        if source.toolbarViewController.toolbarHeight != destination.toolbarViewController.toolbarHeight {
            return
        }

        destination.loadViewIfNeeded()

        guard let sourceSnapshot = source.toolbarViewController.view.snapshotView(afterScreenUpdates: false) else { return }
        let destinationSnapshot: UIView?
        // Expecting `destination`'s view to not be in the view hierarchy yet.
        if destination.view.superview == nil {
            // If the parent of `destination` is not `navigationController` adding `destination.view` to `navigationController.view` would make the app crash.
            // (as the view of a child view controller should be a subview of its parent's view)
            assert(destination.parent == navigationController, "Unexpected state")
            // If `destination.view` is not in the view hierarchy, we cannot take a snapshot
            // of it or of its subviews, so temporarily add it to `navigationController.view`.
            navigationController.view.addSubview(destination.view)
            destination.view.layoutIfNeeded()
            // `destination.toolbarViewController` has not been displayed yet,
            // so `afterScreenUpdates` has to be `true` to be able to get a snapshot.
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: true)
            // `destination`を元の状態に戻す
            destination.view.removeFromSuperview()
        } else {
            assertionFailure("Unexpected state")
            destinationSnapshot = destination.toolbarViewController.view.snapshotView(afterScreenUpdates: false)
        }

        // Animation start state
        // `toolbarBackgroundView` hides the real toolbar.
        let toolbarBackgroundView = UIView()
        toolbarBackgroundView.backgroundColor = source.toolbarViewController.toolbarBackgroundColor
        toolbarBackgroundView.frame = CGRect(
            x: 0,
            y: 0,
            width: source.toolbarViewController.view.bounds.width,
            height: source.toolbarViewController.view.frame.maxY
        )
        coordinator.containerView.addSubview(toolbarBackgroundView)

        sourceSnapshot.frame = source.toolbarViewController.view.frame
        toolbarBackgroundView.addSubview(sourceSnapshot)
        sourceSnapshot.alpha = 1

        if let destinationSnapshot = destinationSnapshot {
            destinationSnapshot.frame = destination.toolbarViewController.view.frame
            toolbarBackgroundView.addSubview(destinationSnapshot)
            destinationSnapshot.alpha = 0
        } else {
            assertionFailure("Unexpected state")
        }

        coordinator.animate(alongsideTransition: { context in
            context.containerView.bringSubviewToFront(toolbarBackgroundView)

            // Animation end state
            destinationSnapshot?.alpha = 1
            sourceSnapshot.alpha = 0
            toolbarBackgroundView.backgroundColor = destination.toolbarViewController.toolbarBackgroundColor
        }, completion: { _ in
            // Remove `toolbarBackgroundView` and its subviews as they were just for the animation.
            toolbarBackgroundView.removeFromSuperview()
        })
    }
}

extension FixedHeightToolbarProvidingContainerViewController: AdditionalToolbarProvidingContainer {
    public var providedToolbarViewController: UIViewController { return toolbarViewController }
    public var embeddedViewController: UIViewController { return embedded }
}

public protocol FixedHeightToolbarViewController: UIViewController {
    // Indicates if the navigation controller's `popViewController()` can be called while this view controller is displayed.
    // (basically should be back button be displayed or not)
    var canPop: Bool { get set }
    // Height of the tool bar (once decided it should not change)
    var toolbarHeight: CGFloat { get }
    // Background color of the toolbar
    var toolbarBackgroundColor: UIColor { get }
}

final class EmbeddedNavigationToolbarViewController: UIViewController, FixedHeightToolbarViewController {
    private let viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func loadView() {
        let navigationToolbar = EmbeddedNavigationToolbar(
            viewController: viewController,
            canPop: canPop
        )
        navigationToolbar.delegate = self
        view = navigationToolbar
    }

    private var toolbar: EmbeddedNavigationToolbar {
        guard let toolbar = view as? EmbeddedNavigationToolbar else {
            fatalError("The view should be an instance of EmbeddedNavigationToolbar")
        }
        return toolbar
    }

    // MARK: FixedHeightToolbarViewController

    var canPop: Bool = false {
        didSet {
            if isViewLoaded {
                toolbar.canPop = canPop
            }
        }
    }

    let toolbarHeight = EmbeddedNavigationToolbar.height
    let toolbarBackgroundColor = EmbeddedNavigationToolbar.backgroundColor
}

extension EmbeddedNavigationToolbarViewController: EmbeddedNavigationToolbarDelegate {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton) {
        navigationController?.popViewController(animated: true)
    }
}

protocol EmbeddedNavigationToolbarDelegate: AnyObject {
    func navigationToolbar(_ navigationToolbar: EmbeddedNavigationToolbar, didTapBackButton backButton: UIButton)
}

final class EmbeddedNavigationToolbar: UIView {
    private static let horizontalMargin: CGFloat = 7.0
    private static let titleViewVerticalMargin: CGFloat = 7.0
    private static let horizontalSpacing: CGFloat = 3.0
    private static let titleViewHorizontalMargin: CGFloat = 7.0
    private static let itemsStackViewMinimumWidth: CGFloat = 2.0
    static let height: CGFloat = {
        // In fact, the height of the OS's navigation bar is not that simple.
        // It's generally 50pt on iPad and 44pt on iPhone, but when displayed in a modal, it seems to be 56pt.
        // I do plan to check if the system presented here would allow this, but have not started yet.
        if UIDevice.current.userInterfaceIdiom == .pad {
            return 50.0
        } else {
            return 44.0
        }
    }()
    static let backgroundColor: UIColor = .lightGray

    private let leftItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    private let rightItemsStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.spacing = horizontalSpacing
        stackView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return stackView
    }()

    private let navigationItem: UINavigationItem
    private var titleView: UIView?
    var canPop: Bool {
        didSet {
            if canPop != oldValue {
                recreateButtons()
            }
        }
    }
    private var sideButtonItemsObservations: [NSKeyValueObservation] = []
    private var titleViewObservation: NSKeyValueObservation?
    weak var delegate: EmbeddedNavigationToolbarDelegate?

    // `viewController` is the view controller that is displayed below this toolbar.
    // The information to display in this toolbar come from it.
    // `canPop` indicates if `viewController` can be popped out of its navigation controller,
    // in other words it indicates that `viewController` is not the first in the navigation controller's navigation stack.
    init(
        viewController: UIViewController,
        canPop: Bool
    ) {
        navigationItem = viewController.navigationItem
        self.canPop = canPop

        super.init(frame: .zero)

        backgroundColor = Self.backgroundColor
        clipsToBounds = true

        leftItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(leftItemsStackView)
        leftItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        leftItemsStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalMargin).isActive = true

        rightItemsStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(rightItemsStackView)
        rightItemsStackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        rightItemsStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalMargin).isActive = true

        // Using KVO so that changes to `titleView`, the view to display in the middle of the toolbar,
        // are reflected as soon as they happen.
        // Using `options: .initial` so that the closure is called not only on changes but also with the current value.
        titleViewObservation = navigationItem.observe(\.titleView, options: .initial) { [weak self, weak viewController] navigationItem, _ in
            guard let self = self, let viewController = viewController else { return }
            self.setUpTitleView(navigationItem.titleView ?? self.makeDefaultTitleView(for: viewController))
        }

        // Observing with KVO `UINavigationItem` properties that have an effect on the toolbar.
        // All the closures look the same, but their parameters have different types so we cannot just use one closure.
        sideButtonItemsObservations = [
            navigationItem.observe(\.leftBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItem) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.rightBarButtonItems) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.hidesBackButton) { [weak self] _, _ in self?.recreateButtons() },
            navigationItem.observe(\.leftItemsSupplementBackButton) { [weak self] _, _ in self?.recreateButtons() },
        ]

        // Create the buttons on both sides from the starting state.
        recreateButtons()
    }

    @available(*, unavailable)
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // The height is fixed so give it to AutoLayout.
    override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: Self.height)
    }

    private var shouldDisplayBackButton: Bool {
        if !canPop {
            return false
        }
        if navigationItem.hidesBackButton {
            return false
        }
        return (navigationItem.leftBarButtonItem == nil || navigationItem.leftItemsSupplementBackButton)
    }

    private func recreateButtons() {
        // As we are recreating the buttons, first remove the previous ones.
        (leftItemsStackView.arrangedSubviews + rightItemsStackView.arrangedSubviews).forEach { $0.removeFromSuperview() }

        var leftBarButtonItems = navigationItem.leftBarButtonItems ?? []
        if shouldDisplayBackButton {
            leftBarButtonItems.insert(makeBackButtonItem(), at: 0)
        }

        Self.makeButtons(from: leftBarButtonItems).forEach { leftItemsStackView.addArrangedSubview($0) }
        // Buttons specified with `rightBarButtonItems` starts from the right, so we have to reverse the order
        // before adding corresponding buttons to the stack view.
        Self.makeButtons(from: navigationItem.rightBarButtonItems).reversed().forEach { rightItemsStackView.addArrangedSubview($0) }

        Self.preventFromExpandingHorizontally(leftItemsStackView)
        Self.preventFromExpandingHorizontally(rightItemsStackView)
    }

    private static func makeButtons(from barButtonItems: [UIBarButtonItem]?) -> [NavigationToolbarButton] {
        return (barButtonItems ?? []).map { item in
            let button = NavigationToolbarButton(barButtonItem: item)
            button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
            return button
        }
    }

    private static func preventFromExpandingHorizontally(_ stackView: UIStackView) {
        assert(stackView.axis == .horizontal, "Vertical stack view are not supported")
        if !stackView.arrangedSubviews.isEmpty {
            return
        }
        let view = UIView()
        view.widthAnchor.constraint(equalToConstant: itemsStackViewMinimumWidth).isActive = true
        stackView.addArrangedSubview(view)
    }

    private func makeBackButtonItem() -> UIBarButtonItem {
        // The `systemItem` passed to `UIBarButtonItem(systemItem:)` cannot be read back after creation.
        // To be able to create a button from a `UIBarButtonItem` we have to be able to get its image,
        // so we cannot use `UIBarButtonItem(systemItem:)`.
        // Instead, making use of SF Symbols, we create a standard image from the symbol.
        let backButtonImage = UIImage(
            systemName: "chevron.backward",
            withConfiguration: UIImage.SymbolConfiguration(pointSize: 23)
        )?.withTintColor(.orange, renderingMode: .alwaysOriginal)
        let backButtonItem = UIBarButtonItem(
            image: backButtonImage,
            style: .plain,
            target: self,
            action: #selector(didTapBackButton)
        )
        backButtonItem.accessibilityLabel = "戻る"
        return backButtonItem
    }

    @objc private func didTapBackButton(_ sender: UIButton) {
        // Just calling the delegate.
        delegate?.navigationToolbar(self, didTapBackButton: sender)
    }

    private func setUpTitleView(_ titleView: UIView) {
        self.titleView?.removeFromSuperview()
        self.titleView = titleView

        titleView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleView)
        titleView.topAnchor.constraint(equalTo: topAnchor, constant: Self.titleViewVerticalMargin).isActive = true
        titleView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.titleViewVerticalMargin).isActive = true
        titleView.leadingAnchor.constraint(
            equalTo: leftItemsStackView.trailingAnchor,
            constant: Self.titleViewHorizontalMargin
        ).isActive = true
        titleView.trailingAnchor.constraint(
            equalTo: rightItemsStackView.leadingAnchor,
            constant: -Self.titleViewHorizontalMargin
        ).isActive = true
    }

    private func makeDefaultTitleView(for viewController: UIViewController) -> UIView {
        let titleView = EmbeddedNavigationToolbarTitleOnlyTitleView()
        titleView.observe(viewController: viewController)
        return titleView
    }
}

final class EmbeddedNavigationToolbarTitleOnlyTitleView: UIView {
    private let titleLabel = UILabel()

    init() {
        super.init(frame: .zero)

        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        addSubview(titleLabel)
        titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 2
        titleLabel.adjustsFontSizeToFitWidth = true
        titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    }

    private var titleObservation: NSKeyValueObservation?

    func observe(viewController: UIViewController) {
        titleObservation = viewController.navigationItem.observe(\.title, options: [.initial]) { [weak self] navigationItem, _ in
            self?.titleLabel.text = navigationItem.title
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

public final class NavigationToolbarButton: UIButton {
    private static let buttonHorizontalPadding: CGFloat = 2.0
    private let barButtonItem: UIBarButtonItem
    private var enabledObservation: NSKeyValueObservation?

    public init(barButtonItem: UIBarButtonItem) {
        self.barButtonItem = barButtonItem

        super.init(frame: .zero)

        if let image = barButtonItem.image {
            setImage(image, for: .normal)
        } else if let title = barButtonItem.title {
            setTitle(title, for: .normal)
            if let tintColor = barButtonItem.tintColor {
                setTitleColor(tintColor, for: .normal)
                setTitleColor(tintColor.heavilyHighlighted, for: .highlighted)
            } else {
                setTitleColor(.black, for: .normal)
            }
            setTitleColor(.gray, for: .disabled)

            if let titleTextAttributes = barButtonItem.titleTextAttributes(for: .normal),
               let font = titleTextAttributes[.font] as? UIFont {
                titleLabel?.font = font
            } else {
                titleLabel?.font = UIFont.systemFont(ofSize: 16)
            }
        } else {
            // Here is one limitation of this system.
            // For some reason, a `systemItem` passed to `UIBarButtonItem(systemItem:)` cannot be read back after creation, so we cannot create a button for it.
            // Also, we should be able to support a `UIBarButtonItem`using a `customView` but we did not have any need for it.
            fatalError("Unsupported button item type \(barButtonItem)")
        }
        contentEdgeInsets = UIEdgeInsets(
            top: 0,
            left: Self.buttonHorizontalPadding,
            bottom: 0,
            right: Self.buttonHorizontalPadding
        )
        accessibilityLabel = barButtonItem.accessibilityLabel
        if let target = barButtonItem.target, let action = barButtonItem.action {
            addTarget(target, action: action, for: .touchUpInside)
        }

        enabledObservation = barButtonItem.observe(\.isEnabled, options: [.initial]) { [weak self] item, _ in
            self?.isEnabled = item.isEnabled
        }

        sizeToFit()

        if #available(iOS 13.4, *) {
            isPointerInteractionEnabled = true
        }
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension UIColor {
    fileprivate var rgbaComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        var red: CGFloat = 0.0
        var green: CGFloat = 0.0
        var blue: CGFloat = 0.0
        var alpha: CGFloat = 0.0

        getRed(&red, green: &green, blue: &blue, alpha: &alpha)

        return (red, green, blue, alpha)
    }

    fileprivate var heavilyHighlighted: UIColor {
        let ratio: CGFloat = 0.85
        let (red, green, blue, alpha) = rgbaComponents

        return UIColor(
            red: red * ratio,
            green: green * ratio,
            blue: blue * ratio,
            alpha: alpha
        )
    }
}

クックパッドマートの多種多様な商品名から、扱いやすい「食材キーワード」を予測する

研究開発部の山口 (@altescy) です.今回は最近開発したクックパッドマートの商品の「食材キーワード」を予測する機械学習モデルを紹介します.

商品の食材キーワード予測とは?

クックパッドマートでは日々様々な食材が多くの販売者から出品されています.出品される商品の情報は販売者によって登録されるため,多様な表記が存在します.「じゃがいも」の商品名を例に挙げると,「ジャガイモ」「じゃが芋」といった表記の揺れや,「メークイン」「インカのめざめ」といった品種名が書かれているもの,「農家直送」や「お徳用」のようなキャッチコピーがついたもの,など様々です.一方で,商品の検索や推薦を行う際にはその商品がいったい何なのかを簡潔に表す情報が欲しくなります.

そこで登場するのが「食材キーワード」です.商品名や商品説明とは別に,その商品がどんな食材なのかを表すキーワードを設定しておくことで,商品名の表記揺れによる検索精度の低下を抑えたり,その食材を利用したレシピの提案をしたりするなどの応用が可能になります.

設定すると便利な食材キーワードですが,出品されている商品は非常に多く種類も多様であるため,これまで出品されてた商品に対して手動でひとつずつ設定していく作業は大きな負担となります.そこで今回は商品の食材キーワードの設定作業を支援するために,商品名から食材キーワードの候補を予測する機械学習モデルの開発を行いました.

データセットの準備

機械学習モデルを学習・評価するために約5,000件の商品に対して食材キーワードのアノテーションを行いました.その結果,作成したデータセットに含まれるユニークな食材キーワードの数は1,300以上あり,その分布もロングテールであることがわかりました.データセットのサイズに対してキーワードの数が多く,事例が2〜3個しかないキーワードも多いため,通常の分類モデルを用いた手法で十分な精度を出すことは難しそうです.

一見難しそうな分類問題に見えますが,実際にデータを見てみると商品名の中には食材キーワードに対応する言葉が入っている場合がほとんどです.例えば,食材キーワードとして「にんじん」が設定された商品名をみると「にんじん」「人参」「キャロット」などの単語が入っていたり,「えび」がキーワードに設定されている商品名なら「エビ」「海老」あるいは「ブラックタイガー」のような単語が含まれています.

このような性質から,食材キーワードを単なるラベルとして予測するよりも,商品名と食材キーワードの類似性や対応関係を考慮して予測を行った方が良い結果が得られそうです.そこで,今回は商品名とキーワードの類似性に基づいて 距離 *1 を学習するようなモデルを試すことにしました.

食材キーワード予測モデル

モデルの仕組み

作成したモデルは以下の図のような構造です.商品名・キーワードをそれぞれニューラルネットワークを用いてベクトルに変換して,ベクトル同士の距離を商品名・キーワード間の距離として利用します.

モデルの構造
モデルの構造

処理の流れ

  1. 商品名の前処理 (全角・半角を揃える,数値やストップワードの削除など)
  2. 商品名・キーワードを単語に分割
  3. 2で分割した単語を fastText を用いて対応する単語ベクトルに変換
  4. 3で作った商品名・キーワードの単語ベクトル系列をそれぞれ LSTM を用いて変換
  5. 4で変換した商品名の単語ベクトルを,キーワードの単語ベクトル列を使って重み付け (後述)
  6. 商品名・キーワードの単語ベクトル系列をそれぞれ平均して商品名・キーワードのベクトルを作る
  7. 6で作った商品名・キーワードのベクトル同士のコサイン距離を商品名とキーワードの距離とする

fastText はクックパッドで公開されている350万レシピを用いて事前に学習したものを利用しました.

学習手法

ペアになる商品名・キーワード同士の距離が近くなるようにモデルの学習を行います.ただし,単純にペア同士の距離が近づくように学習するとすべての商品名・キーワードを同じ1点に集めれば距離を0にできてしまうため,負例サンプリングを行ってペアにならない商品・キーワード同士は距離がなるべく離れるように学習します.

商品名の単語の重み付け(注意機構)について

商品名の単語の重み付け(注意機構)について詳しく説明します.

商品名の単語に対して重み付けをする動機は,商品名に含まれている「◯◯県産」や「農家直送」など,キーワード予測においては本質的でない単語の影響をなるべく取り除きたいからです.簡単なものであれば前処理の段階でルールに基づい て取り除くことができますが,「昔ながらの...」や「肉の日限定10%オフ!」など,商品名に含まれるパターンは多様であり,事前にルールのみで対処することはなかなか困難です.

そこで,今回のモデルではキーワードとの類似度を利用して商品名の単語に対する重みを計算し,キーワードに関連のある単語の影響が強くなるようにしました.下の図が商品名の単語の重みを計算する様子です.

単語の重み付けの仕組み
単語の重み付けの仕組み

商品名とキーワードの単語同士で類似度を計算したあと,キーワードの単語方向に平均した結果が商品名の単語の重みになります.こうすることでキーワードに含まれる単語に似た単語の重みは大きくなり,関連性の低い単語は相対的に重みが小さくなります.例えば,この図では「カラー」や「にんじん」といった単語の重みが大きくなり,「淡路島」などキーワードと関連性の低い単語の影響を小さくできそうです.

モデルの精度

アノテーションしたデータのうち,約1,500件をテストデータとしてモデルを評価した結果が以下の表になります.LSTM分類器はベースラインとして fastText + LSTM で商品名から直接食材キーワードを分類したモデル,距離学習モデルが上で述べた手法,距離学習モデル(注意機構なし)が距離学習モデルから注意機構による単語の重み付けをなくしたモデルになります.

Accuracy@1 Accuracy@5 MRR
LSTM分類器 62.5 72.5 67.1
距離学習モデル (注意機構なし) 75.2 91.3 82.3
距離学習モデル 78.5 95.5 85.7

最も精度の高い距離学習モデルの上位5件の正解率 ( Accuracy@5 ) は 95.5% でした.この結果から,最も関連があると予測されたキーワード数個を選んだ場合に高い割合で正解のキーワードを含むモデルになっていることがわかります.

距離学習モデルの結果をLSTM分類器のものと比較すると Accuracy@5 では +20% 以上の改善がみられました.これは商品名と食材キーワードの類似性を捉える距離学習の仕組みが役に立ったと言えそうです.

また,注意機構ありとなしの場合で比較してみるとそれぞれの指標で数ポイントずつですが注意機構を導入した場合の方が良い結果が得られました.

考察

今回作成したモデルの性質についてわかったことをまとめます.

まず、良いと感じた性質について述べます:

  • 表記揺れの吸収: レシピデータで学習したfastTextは,その単語が使われる文脈に含まれる他の単語との共起性に基づいて作られるため,「人参/にんじん」や「鯵/アジ/あじ」といった表記の揺れを吸収することができます.また「サンふじ/りんご」や「五郎島金時/さつまいも」のような品種名と一般名詞との関係もある程度考慮できているようです.
  • キーワードの拡張性: このモデルはキーワードを直接予測する分類モデルと異なり商品名とキーワードをそれぞれ与えて距離を測るため,再学習することなくキーワードを拡張することができます.もちろん予測したいキーワードが学習データに含まれていた方が好ましい結果が得られやすいとは思いますが,学習データに含まれていないキーワードであってもある程度妥当な距離が計算できそうです.

一方で,このモデルにはいくつかの課題があると感じています:

  • 推論速度: このモデルは商品名が与えられたときにキーワード群の全てのキーワードとペアを作って距離を計算する必要があります .そのため,キーワード数や推論対象の商品が多くなった場合には計算量が多くなり推論速度の点でボトルネックになりそうです.
  • ハブネス問題: fastTextでは多くの文脈で登場する単語ベクトル同士は互いに近くなります.そのため高頻度で現れる単語が商品名 ・キーワードに含まれていると,商品の性質に関わらず互いに近くなる傾向があります.例として「白」が含まれる単語同士は互いに近くにあるらしく「白ワイン」に対する推薦結果に「白なす」や「白玉ねぎ」が含まれたりします.このように区別したい性質を超え て多くのデータ点と近いベクトルが現れることは「ハブネス問題」と呼ばれていて,特に高次元ベクトルの近傍を利用するモデルにおいて課題になることが知られています(Radovanović+, 2010)

食材キーワード予測モデルの活用

今回作成した食材キーワード予測モデルは,現在社内向けサービスとして食材キーワードの設定を支援するために利用しています.予測結果の上位5件をキーワード設定フォームの上部にボタンとして表示し,これをクリックすると選択したキーワードが入力される,という感じです.設定者が適切な候補を考える手間が省けるため,効率的に食材キーワードの設定ができるようになったと思います.

食材キーワードの設定画面
食材キーワードの設定画面

まとめ

クックパッドマートの商品の食材キーワードを予測するタスクについて紹介しました.大量のレシピデータで学習した fastText と注意機構を活用することで,比較的小規模なデータセットであっても1,000クラス以上の分類問題をある程度の精度で解くことができるようになりました.

今回作成したモデルは現在社内向けのサービスとしてのみ利用していますが,今後は販売者向けの管理画面などにも利用できるようにモデルの計算量の削減や予測性能性能の向上を模索したいと思います.

*1:注意機構が商品名と食材キーワードで非対称なので厳密には距離ではないですね