LoginSignup
5
1

More than 3 years have passed since last update.

「Build a movie search app using React Hooks」をガン無視してAngularでつくる[前編]

Last updated at Posted at 2019-12-06

きっかけ

2020年のフロントエンドマスターになりたければこの9プロジェクトを作れ
こちらを読んでて、「よっしゃフロントエンドマスターになったろ!」とか思って早速作ろうと思ったのですが、一番最初の課題がバチバチにReact向けでした。
Build a movie search app using React Hooks
とはいえ、フレームワークにこだわる必要なんてない(語弊)ので、このアプリをAngularで作ることにします。

設計

目標
とりあえずこのスクショがおおよそ再現できればいいやということで、こいつをみながらいろいろ考えてみました。

必要なコンポーネントの洗い出し

画面配置を考えながら、設計図を描いていきます。
前回にも書いたのですが、設計は大雑把でも構わないので、手書きで全体像や部品を描くことが大事だと思います。
絵の情報量はテキストよりも大きい上に、理解がしやすいし間違いに気づきやすいですからね。

実際に起こした図
movielist07.png
自分の描いた図をもとに、画面とコンポーネントの設計を行なっていきます。

画面名 役割
list 映画の一覧が表示される
details 映画の詳細が表示される
コンポーネント名 役割
top-bar タイトルが乗っかるところ
movie-search 映画の検索フォーム
movie-list 映画一覧
page-control 映画一覧の切り替えボタン
movie-detail 映画の詳細

次にルーティングです。
遷移のみならず、ブラウザに表示されるパスによって表示するコンポーネントを設定するには、Angularではルーティングを行わなければなりません。(クエリ使うのはノーカンで)

const routes: Routes = [
  { path: 'detail/:id', component: MovieDetailComponent },
  { path: 'list/:page', component: MovieListComponent },
  { path: '*', redirectTo: '/list/1', pathMatch: 'full' },
];

この書き方が許されてるかはさておき、この形式で設計していきます。

使用するAPI

映画の情報を取りたかったので、The Movie DB(通称TMDb)のAPIを使用することにしました。

今まで外部のAPIを使うことはあまりなかったので無知なだけかもしれませんが、こちらのページではAPIの解説とても丁寧にされていて、初心者にもとても優しいです。

TMDb API

ScrenCaptured 2019-12-06 13.43.15.png

Keyを取得してればページ内で実際に叩けるので、検証も非常にしやすいです。

各コンポーネントの設計

movie-list

映画一覧を表示するコンポーネントです。
この図を再現したいので、横は4つ、縦はAPIで取れるだけ、といった形でページレイアウトを考えます。
とはいえ、そもそもAPI叩けなければ何も始まらないので、そこからいきましょう。

movies.tsの作成

APIの仕様をもとに、クラスファイルを作ります。
別になくてもデータはとれなくもないのですが、型をStringだとかanyだとかで取りたくないので、厳格にいきましょう。
movies.tsが映画一覧、movie.tsが映画詳細情報に対応します。

movies.ts
export class Movies {
  results: {
    popularity: number;
    vote_count: number;
    video: boolean;
    poster_path: string;
    id: number;
    adult: boolean;
    backdrop_path: string;
    original_language: string;
    original_title: string;
    genre_ids: number[];
    title: string;
    vote_average: number;
    overview: string;
    release_date: string;
  }[];
  page: number;
  total_results: number;
  dates: {
    maximum: string;
    minimum: string;
  };
  total_pages: number;
}
movie.ts
export class Movie {
  adult: boolean;
  backdrop_path: string;
  belongs_to_collection: object;
  budget: number;
  genres: {
    id: number;
    name: string;
  };
  homepage: string;
  id: number;
  imdb_id: string;
  original_anguage: string;
  original_title: string;
  overview: string;
  popularity: number;
  production_companies: {
    name: string;
    id: number;
    logo_path: string;
    origin_country: string;
  };
  production_countries: {
    iso_3166_1: string;
    name: string;
  };
  release_date: string;
  revenue: number;
  runtime: number;
  spoken_languages: {
    iso_639_1: string;
    name: string;
  };
  status: string;
  tagline: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
}

movie.serviceの作成

コンポーネント内でAPIを叩くのはナンセンスなので、API叩くようにserviceを作ります。
ページ一覧と詳細が取れればいいので、こちらもAPI仕様を見ながら作成しました。

import { Movies } from "./movies";
import { Movie } from "./movie";

@Injectable({
  providedIn: 'root'
})
export class MovieService {
  private now_playing_url = 'https://api.themoviedb.org/3/movie/now_playing';
  private search_url = 'https://api.themoviedb.org/3/search/movie';
  private API_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
  private lang = 'ja-JA'

  httpOptions = {
    headers: new HttpHeaders({ 'Content-Type': 'application/json' })
  };

  constructor(
    private http: HttpClient,
  ) { }

  getNowPlayingMovies (page: number): Observable<Movies> {
    const url = ` ${this.now_playing_url}?api_key=${this.API_KEY}&language=${this.lang}&page=${page}` ;
    return this.http.get<Movies>(url)
  }

  searchMovies (page: number, query: string): Observable<Movies> {
    const url = ` ${this.search_url}?api_key=${this.API_KEY}&query=${query}&language=${this.lang}&page=${page}` ;
    return this.http.get<Movies>(url)
  }

  getMovie (id: number): Observable<Movie> {
    const url = ` https://api.themoviedb.org/3/movie/${id}?api_key=${this.API_KEY}&language=${this.lang}` ;
    return this.http.get<Movie>(url)
  }
}

urlをstringで直書きして叩くよわよわリクエストです…
クエリの設定はなぜか通らないことがあったので今回は端折ってます、今後のことを考えたらやらなきゃですね

映画の一覧は検索を後回しにして、とりあえずnow_playngで取得することになりました。
現在上映中の映画のリストが取れるそうです。たぶん公開日新しい順で出してるだけかな?

movie-list.html

APIからのデータが取れてる前提で設計します。

Angularでは、コンポーネントで定義した変数がHTML側に簡単に表示できます。
二重中カッコ{{}}で変数名を囲むと参照できるので、そちらをガンガン利用していきます。
またリストに関しては*ngForを使うとイテレートできるので、それを利用して一覧表示させます。

movie-list
<div class="movie-list">
  <div *ngFor="let movie of movies.results">
    <h4 align="center" class="title">{{movie.title}}</h4>
    <a routerLink="/detail/{{movie.id}}">  
      <img
        src="https://image.tmdb.org/t/p/w300{{movie.poster_path}}"
        class="movie">
    </a>
    <h4 align="center" class="release">{{movie.release_date}}</h4>
  </div>
</div>

2行目の*ngFor="let movie of movies.results"で、映画一覧が挿入されているmoviesから、一要素ずつmovieに入れて取り出しています。

横に4つ置いたり、ページの横幅が変わったらリサイズしたりする手法は、Webデザインの全てを網羅している、しゅたいふぇ(@shti_f)くんに教わりながらCSSカキカキしました。

movie-list.ts

検索結果も全てここに表示したいがために、ちょっとスパゲッティ感が出てしまいました。
自分の今のコーディング力で理想とする動きを再現するには、まあまあ上出来と思ってます。

movie-list.ts
export class MovieListComponent implements OnInit {
  movies: Movies;
  requestObj: PageRequest;
  page = 1;
  path:string[] =  _.map(this.route.snapshot.pathFromRoot, (ars: ActivatedRouteSnapshot) => {
    if (_.isNil(ars.url) || _.isEmpty(ars.url)) {
     return null;
    }
    return (_.first(ars.url) as UrlSegment).path as string;
   }).filter(val => val);

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private movieService: MovieService
  ) {}

  ngOnInit() {
    const requestObj = new PageRequest();
    requestObj.page  = +this.route.snapshot.paramMap.get('page');
    requestObj.query = this.path.includes('search') ? this.route.snapshot.paramMap.get('query') : ''; 
    requestObj.type  = this.path.includes('search') ? 'search' : 'nowplaying';
    this.getPage(requestObj)
  }

  getMovies(page: number): void{
    this.movieService.getNowPlayingMovies(page)
    .subscribe(movies => this.movies = movies );
  }

  searchMovies(page: number, query: string): void{
    this.movieService.searchMovies(page, query)
    .subscribe(movies => {
      this.movies = movies

    });
  }

  getPage(recievObj: PageRequest): void{
    console.log(recievObj)
    this.page = recievObj.page
    recievObj.query ? this.searchMovies(recievObj.page, recievObj.query) : this.getMovies(recievObj.page);
    this.router.navigate(['/'+recievObj.type, recievObj.query, this.page]);
  }

}

(書いてて気づきましたが、関数名が正しく統合されてませんね)
今のパスを見て自分の状態を確認している設計になってます。
これが色々複雑な事情でこうなってしまったので、次作るアプリはここを解決していきたいと思います。

できたもの

そんなこんなしてたらこうなりました。
movielist08.png
これだけでもうそれっぽいです、見た目だけなら9割方完成です。
とはいえ、これだと直近16本のタイトルとポスターしか見れないので、詳細ページを作っていきます。

movie-details

詳細ページは大したことしていません、なんなら一覧ページ作るのに必死で、こちらはガッツリ手抜きです。

movie-detail.html

movie-detail.html
<div *ngIf="movie" align="center">
<table>
  <tr>
    <td rowspan=5>
      <img src="https://image.tmdb.org/t/p/w342{{movie.poster_path}}">
    </td>
  </tr>
  <tr>
    <td width="400">
      <h2>{{movie.title}}</h2>
    </td>
  </tr>
  <tr>
    <td>
      {{movie.release_date}}
    </td>
  </tr>
  <tr>
    <td>
      <img width="400" src="https://image.tmdb.org/t/p/w780{{movie.backdrop_path}}">
    </td>
  </tr>
  <tr>
    <td>
      {{movie.overview}}
    </td>
  </tr>
</table>

</div>

<button (click)="goBack()">go back</button>

言いたいことはわかります、でも許してください、とりあえず見えればそれでいいと思ったのです……
映画詳細に関しては、先ほど作ったmovie.serviceでしれっとAPIリクエスト文作ってます。

できたもの

movielist09.png
うまく取れてますね。
(余談ですが、シャイニング実は今年初めて観ました。原作と設定が色々違うとのことで賛否両論ですが、私は結構好きです。近々スピンオフだか続編だかが上映されるので楽しみですね)

page-control

こいつのおかげで一番もめました。
理由としては私がルーティングを正しく理解してないところにあったのですが…
最終的にパワー的解決になったので、それも含めてご覧ください。

設計思想

目標はこのようなものを考えていました。
movielist01.png
真ん中にページ数、左右にBACKとNEXT、ページが存在しない方向には飛べない仕様です。
movie-listの子コンポーネントにあたるので、親から今のページ番号やpageGet関数なるものをもらっています。
<router-link>を使えばページ遷移は簡単にできるはずなんですが、同じコンポーネント内だと作用しないっぽいんですよね。
コンポーネント内で画面遷移っぽく見せつつ、毎回API叩かなきゃいけないので、このような形になってます。

page-control.html

page-control.html
<div>
  <!-- 先頭ページでなければBACKボタンを表示 -->
  <span *ngIf="page-1">
    <h3 class="back" (click)="getPage(page-1)"> BACK </h3>
  </span>
  <!-- 先頭ページであればBACKボタンを非表示 -->
  <span *ngIf="!(page-1)">
    <h3 class="cantback"> BACK </h3>
  </span>

  <h3 class="num">{{page}}</h3>

  <!-- 最終ページでなければNEXTボタンを表示 -->
  <span *ngIf="total-page"> 
    <h3 class="next" (click)="getPage(page+1)">NEXT</h3>
  </span>
  <!-- 最終ページであればNEXTボタンを非表示 -->
  <span *ngIf="!(total-page)">
    <h3 class="cantnext">NEXT</h3>
  </span>
</div>

page-control.ts

page-control.ts
export class PageContorolComponent implements OnInit {
  @Input() page: number;
  @Input() requestObj: PageRequest;
  @Input() total: number;
  @Output() onGetPage = new EventEmitter<PageRequest>();

  constructor(
    private route: ActivatedRoute
  ) { }

  ngOnInit() {
  }

  getPage(page: number): void{
    const sendObj = new PageRequest();
    sendObj.page = page;
    sendObj.query = this.route.snapshot.paramMap.get('query');
    sendObj.type = sendObj.query ? 'search' : '';
    this.onGetPage.emit(sendObj)
  }
}

できたもの

movielist10.png
NEXTをクリックするとAPIが叩かれて、次のページにずらっと一覧が並びます。

前編まとめ

前編ではmovie-list, page-control, movie-detailの3つのコンポーネントについて簡単に説明しました。
後編ではmovie-serchコンポーネントと、ページ設計やルーティングの設定などについて書いていきたいと思います。
後編を終えたら、いよいよ当アドベントカレンダーのメインになる新規Webアプリの設計に入ります。
映画が好きなので、このWebアプリをもっと拡張できればいいんですが、何はともあれ頑張っていきましょう。

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1