NCMBとFramework7を使ったリスト・詳細コンポーネントの紹介と使い方

NCMBはモバイルアプリ開発におけるバックエンド機能（認証、データストア、ファイルストア、プッシュ通知など）を提供しています。バックエンドなのでAPIベースで利用するのが基本で、UI（アプリ側）は各自で開発する仕組みになっています。

現在、数多くのアプリが存在し、その中には定番とも言える機能があります。そうした定番機能を各フレームワークごとに実装しておくことで、再利用性高くNCMBが利用できるかと思います。

今回はFramework7で作ったリスト・詳細コンポーネントを紹介します。Monacaアプリでも利用可能です。

## UIについて

コンポーネントは2つのHTMLだけで実装されているのが特徴です。そのため、基本的には以下の方法で導入・利用ができます。

1. 必要なライブラリ、NCMB SDKの読み込み
1. NCMBのキーの取得
1. データの登録
1. NCMBの初期化
1. ルーティングの設定
1. リスト・詳細UI（HTML）を配置

用意されている画面（機能）は次の通りです。

### リスト画面

![FireShot Capture 078 - 20220218165333 List - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/77901c4e-6f71-c5fb-e9e9-162a3371f46d.jpeg)


データを一覧表示します。画像のサムネイル表示に対応しています。表示する項目は要件に合わせてカスタマイズしてください。

### 詳細画面

![FireShot Capture 078 - 20220218165334 List - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/a4051d07-4194-ae66-eb19-a8b4dc58e8a1.jpeg)


リスト画面で選択したデータを詳細表示します。表示する内容については要件に合わせてカスタマイズしてください。

## 使い方

ではここからは使い方を紹介します。

### 必要なライブラリ、NCMB SDKの読み込み

今回利用しているライブラリは次の通りです。

- NCMBのJavaScript SDK
- Moment.js

```html
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment-with-locales.min.js" integrity="sha512-LGXaggshOkD/at6PFNcp2V2unf9LzFq6LE+sChH7ceMTDP0g2kn6Vxwgg7wkPP7AAtX+lmPqPdxB47A0Nz0cMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="js/ncmb.min.js"></script>
```

### 必要なキーの取得

NCMBのアプリケーションキーとクライアントキーを取得します。

- [ニフクラ mobile backend](https://mbaas.nifcloud.com/)

### データの登録

今回はブログのフィードをコンテンツとして登録します。NCMBのスクリプト機能を利用しており、そのコードはここにあります。GETリクエストで実行可能で、 `url=(RSSフィードのURL)` として実行すれば、Feedクラス（フィード情報）とEntryクラス（フィードの各記事）ができあがります。構造は次の通りです。

#### Feedクラス

フィードはfetchDateを使って、1時間以上経過したデータだけを再取得の対象としています。

| フィールド名 | 型 | 説明 |
|------------------|------------------|------------------|
|	objectId	| 文字列型 | ユニークIDです |
|	createDate	| 日付型 | データ作成日時。規定のフィールドです |
|	updateDate	| 日付型 | データ更新日時。規定のフィールドです |
|	acl	| オブジェクト | アクセス権限 |
|	url	| 文字列型 | RSSのURLです |
|	fetchDate	| 日付型 | 最後にRSSフィードを取得した日時です |

#### Entryクラス

Feedクラスのポインターを持ち、フィードの記事が登録されるクラスです。

| フィールド名 | 型 | 説明 |
|------------------|------------------|------------------|
|	objectId	| 文字列型 | ユニークIDです |
|	createDate	| 日付型 | データ作成日時。規定のフィールドです |
|	updateDate	| 日付型 | データ更新日時。規定のフィールドです |
|	acl	| オブジェクト | アクセス権限 |
| title       | 文字列型 | 記事のタイトル |
| pubDate       | 日付型 | 記事の発行日時 |
| link       | 文字列型 | 記事へのリンク |
| guid       | 文字列型 | 記事のユニークID |
| author       | 文字列型 | 執筆者（今回は利用しません） |
| thumbnail       | 文字列型 | 記事のサムネイル画像URL |
| description       | 文字列型 | 記事内容 |
| content       | 文字列型 | 記事内容（今回は利用しません） |
| enclosure       | オブジェクト |  メタ情報（今回は利用しません） |
| categories       | 配列型 | カテゴリーが配列で入っています |
| feed       | ポインター | Feedクラスへのポインターです |

### NCMBの初期化

`www/js/app.js` にてNCMBを初期化します。今回は `www/js/config.json` というファイルにキーを記述しているので、以下のように読み込みを行っています。

```js
const $ = Dom7;
(async () => {
  const device = Framework7.getDevice();
	// 設定ファイルの読み込み
  const config = await (await fetch('./js/config.json')).json();
	// NCMBの初期化
  window.ncmb = new NCMB(config.applicationKey, config.clientKey);

  // Framework7の初期化
  window.app = new Framework7({
    name: 'NCMB Notice',
    theme: 'auto',
    el: '#app',
    id: 'com.nifcloud.mbaas.map',
    store: store,
    routes: routes,
    input: {
      scrollIntoViewOnFocus: device.cordova && !device.electron,
      scrollIntoViewCentered: device.cordova && !device.electron,
    },
    statusbar: {
      iosOverlaysWebView: true,
      androidOverlaysWebView: false,
    },
    on: {
      init: function () {
        if (this.device.cordova) {
          cordovaApp.init(this);
        }
      },
    },
  });
})();
```

### ルーティングの設定

今回は最低限のルーティングを設定しています（ `www/js/routes.js` ）。 `/list/(オブジェクトID)` でリストコンポーネントを表示します。このオブジェクトIDはNCMBのFeedクラスのユニークIDになります。 `/list/entries/(オブジェクトID)` は記事詳細のルーティングになります。こちらのオブジェクトIDはNCMBのEntryクラスのユニークIDになります。

つまり、`/list/(オブジェクトID)` のオブジェクトIDを使い分けることで、複数のニュースリソースに対応した一覧表示が可能です。

```js
const routes = [
  {
    path: '/',
    url: './index.html',
  },
  {
    path: '/list/entries/:objectId',
    componentUrl: './pages/detail.html',
  },
  {
    path: '/list/:objectId',
    componentUrl: './pages/list.html',
  },
  {
    path: '(.*)',
    url: './pages/404.html',
  },
];
```

`www/index.html` で `/list/2YAtnb0nxjjfbeZ3` を最初に表示します。 `2YAtnb0nxjjfbeZ3` は筆者環境でのFeedクラスのオブジェクトIDなので、自分のものに置き換えてください。

```html
<div id="app">
	<!-- Your main view, should have "view-main" class -->
	<div class="view view-init safe-areas" data-url="/list/2YAtnb0nxjjfbeZ3">
  </div>
</div>
```

### リスト・総裁コンポーネント（HTML）を配置

後は以下の2つのファイルをダウンロードして `www/pages` 以下に配置してください。

- [list.html](https://raw.githubusercontent.com/NCMBMania/framework7_list_component/main/www/pages/list.html)
- [detail.html](https://raw.githubusercontent.com/NCMBMania/framework7_list_component/main/www/pages/detail.html)

これで準備完了です。

## リストコンポーネントについて

リストコンポーネントの内容です。詳細はコメントを参照してください。

```html
<template>
  <div class="page">
    <div class="navbar">
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
        <div class="title">一覧</div>
      </div>
    </div>
    <div class="page-content">
      <div class="list media-list">
        <ul>
          ${entries.map(entry => $h`
            <li>
              <a href="#" class="item-link item-content" @click=${() => moveDetail(entry)}>
                <div class="item-media">
                  <img src="${entry.thumbnail}" class="thumbnail" />
                </div>
                <div class="item-inner">
                  <div class="item-title-row">
                    <div class="item-title">${entry.title}</div>
                    <div class="item-after">${viewDate(entry.pubDate)}</div>
                  </div>
                  <div class="item-subtitle">${entry.categories.join(', ')}</div>
                  <div class="item-text">${showText(entry.content)}</div>
                </div>
              </a>
            </li>
          `)}
        </ul>
      </div>
    </div>
  </div>
</template>
<style>
  .thumbnail {
    width: 80px;
    height: 80px;
    object-fit: cover;
  }
</style>
<script>
  export default function (props, { $f7, $f7route, $f7router, $update, $onMounted }) {
    // NCMBが初期化されているかチェックします
    if (typeof ncmb === 'undefined') throw 'NCMBが初期化されていません';
    if (typeof moment === 'undefined') throw 'Momentが読み込まれていません';
    // 日本語表示設定
    moment.locale('ja');
    // 表示する記事一覧を入れる配列
    const entries = [];
		// マウントされた際に実行される関数
    $onMounted(async () => {
			// 前の画面から送られてくるオブジェクトID
      const { objectId } = $f7route.params;
      (await getEntries({ objectId })).forEach(entry => {
        entries.push(entry);
      });
      $update();
    });

		// 詳細画面への遷移を行うイベント
    const moveDetail = (entry) => {
      $f7router.navigate(`/list/entries/${entry.objectId}`, {
        props: {
          browserHistory: true,
          entry
        }
      })
    };

		// 記事一覧を取得する関数
    const getEntries = ({ objectId }) => {
      return ncmb.DataStore('Entry')
        .equalTo('feed', {
          __type: 'Pointer',
          className: 'Feed',
          objectId
        })
        .limit(20)
        .fetchAll();
    };

		// 日付を相対表示する関数
    const viewDate = (date) => {
      return moment(date).fromNow();
    };

		// HTMLがある内容をテキスト部分だけにする関数
    const showText = (html) => {
      const tmp = document.implementation.createHTMLDocument("New").body;
      tmp.innerHTML = html;
      return (tmp.textContent || tmp.innerText || "").split('').slice(0, 100).join('');
    };

    return $render;
  }
</script>
```

## 詳細コンポーネントについて

詳細表示を行うコンポーネントです。

```html
<template>
  <div class="page">
    <div class="navbar">
      <div class="navbar-bg"></div>
      <div class="navbar-inner sliding">
				<div class="left">
					<a href="#" class="link back">
						<i class="icon icon-back"></i>
						<span class="if-not-md">戻る</span>
					</a>
				</div>
					<div class="title">${entry.title}</div>
      </div>
    </div>
    <div class="page-content">
			<div class="card">
        <div style="background-image:url(${entry.thumbnail})"
          class="card-header align-items-flex-end">
					<div class="header-text">${entry.title}</div>
				</div>
        <div class="card-content card-content-padding">
          <p class="date">${moment(entry.pubDate).format('lll')}</p>
          <p>${viewText(entry.description)}</p>
        </div>
        <div class="card-footer">
					<a href="#" class="link" @click=${() => { window.open(entry.link, '_system')}}>
						<i class="f7-icons">globe</i>
					</a>
				</div>
      </div>
		</div>
  </div>
</template>
<style>
	.page-content .card-header {
		height: 200px;
	}
	.page-content .card-header::before {
	  content: '';
	  background-color: rgba(0, 0, 0, 0.8);
	  position: absolute;
	  top: 0;
	  left: 0;
	  width: 100%;
	  height: 100%;
	  display: block;
	}
	.page-content .card-header .header-text {
		z-index: 100;
		color: white;
	}
</style>
<script>
  export default function (props, {$f7, $f7route, $router, $update, $onMounted }) {
    // NCMBが初期化されているかチェックします
    if (typeof ncmb === 'undefined') throw 'NCMBが初期化されていません';
    if (typeof moment === 'undefined') throw 'Momentが読み込まれていません';

		let { objectId, entry } = props;
		if (!entry) entry = {};
		// マウントされたら実行されるイベント
		$onMounted(async () => {
			if (entry.objectId) return;
			// entryオブジェクトがなければ取得し直す
			entry = await ncmb
				.DataStore('Entry')
				.equalTo('objectId', objectId)
				.fetch();
			$update();
		});

		// HTMLからテキストだけを抽出して返す関数
		const viewText = (html) => {
			const tmp = document.implementation.createHTMLDocument("New").body;
			tmp.innerHTML = html;
			return (tmp.textContent || tmp.innerText || "").split('').slice(0, 300).join('');
		};

    return $render;
  }
</script>
```

## カスタマイズポイント

今回はFeedクラス、Entryクラスの組み合わせになっていますが、クラス名を変更する場合にはそれぞれ変更してください。また、表示項目が異なる場合にはHTMLテンプレートの内容を修正してください。

なお、ブログ記事のHTMLをアプリ内できれいに表示するのはとても難しく（コードなどもあるため）、今回はテキストを表示するにとどめています。コンテンツによっては詳細画面をもっとリッチにできるでしょう。

## まとめ

今回のリスト・詳細コンポーネントを使えば、一覧から詳細を表示するというよくあるマスター/ディテイル表示が簡単に実装できます。データはすべてNCMBのデータストアにあるので、アプリ側のコードはシンプルなままです。

ぜひNCMBとFramework7を使ってアプリ開発にチャレンジしてください。