Qiita
Android
iOS
ionic

Qiitaの記事一覧を表示するだけのスマホアプリをIonicでサクッと作ってみる

マルチプラットフォーム向けのモバイルアプリが作れるIonicで、Qiitaの記事一覧を表示するアプリをサクッと作ってみました。作り方なので、便利なQiitaアプリを公開しましたという記事ではありません。あと、Angular, TypeScriptは詳しくないので、微妙な書き方をしているかもしれないのでご注意を。

今回のアプリ画面
記事一覧画面.png

ブランクプロジェクトの作成

環境構築は省略して、まずはブランクプロジェクトをつくります。
ターミナルを開いて、

ionic start Qiita blank

でQiitaフォルダに、ブランクプロジェクトができます。

ionic serveコマンドで、ブラウザで動作確認。

cd Qiita
ionic serve --lab

うまくブランクプロジェクトが作成できていれば、しばらくするとiPhone, Android, Windwos向けの画面が確認できる画面がブラウザでひらきます。表示する画面は、右上のPlatformsから選べます。

ブランクプロジェクト画面.png

確認が終わったら、ターミナル側でCtrl+Cで止めます。

Provider作成

次に、Qiita APIを使って、記事一覧を取得するためのProviderを作っていきます。

ionic g provider qiita-service

実行後、Qiita/src/providers/qiita-service/qiita-service.tsに、Providerのファイルができます。

別途、APIの結果用のインタフェースをエディタなど書いて、Qiita/src/providers/qiita-service/QiitaItem.tsを作成します。

Qiita/src/providers/qiita-service/QiitaItem.ts
export interface QiitaItem {

}

※サクッとなので中身は空ですが、本来であればAPIで取得できる変数などを定義するようです。

続いて、記事一覧をQiita APIで取得するためのgetQiitaItems()をqiita-service.tsに定義したり、いろいろ編集します。なお、_serverError()でエラー時の処理をしてますが、err.json().messageの部分は、WebAPIのサービスのエラーメッセージにあわせる必要があります。

Qiita/src/providers/qiita-service/qiita-service.ts
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import 'rxjs/add/operator/map';
import { Observable } from 'rxjs/Rx';
import { QiitaItem } from './QiitaItem';
import 'rxjs/add/observable/throw';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/catch';

@Injectable()
export class QiitaServiceProvider {

  constructor(public http: Http) {
  }

  private _serverError(err: any) {
    console.log('sever error:', err);
    if(err instanceof Response) {
      return Observable.throw(err.json().message || 'データ取得エラー');
    }

    return Observable.throw(err || 'データ取得エラー');
  }

  getQiitaItems(page, queryOption): Observable<QiitaItem[]>{
    var url:string = '';
    let perPage = 10;

    url = "https://qiita.com/api/v2/items?page=" + page + "&per_page=" + perPage;
    if (queryOption != null &&  queryOption != '') {
      url += ("&query=" + queryOption);
    }

    return this.http.get(url)
      .map(res => <Array<QiitaItem>>res.json())
      .do(data => console.log('server data:', data))
      .catch(this._serverError);
  }

}

app.module.tsファイルに、Providerで利用してるHttpModuleを追加します。

Qiita/src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { HttpModule } from '@angular/http';
import { QiitaServiceProvider } from '../providers/qiita-service/qiita-service';

@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    HttpModule,
    IonicModule.forRoot(MyApp)
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    QiitaServiceProvider,
  ]
})
export class AppModule {}

取得結果を表示する画面の作成

取得結果を表示するために、ブランクプロジェクトを作ったときにデフォルトで作成された画面(home.html)を変更します。

今回の画面構成は、

  • ヘッダー ion-header
  • コンテンツ ion-content
    • 一致記事がない時に表示するカード(API取得結果のサイズが0のときに表示)
    • API取得結果をループして、カードリストを表示する部分
    • エラーが起きた時に表示するカード

です。

使うUIコンポーネントは、Ionicで使えるコンポーネントのドキュメントを参考に。(https://ionicframework.com/docs/components/)

APIの取得結果で値は、Qiita APIのドキュメントの取得結果例などを参考に。
(https://qiita.com/api/v2/docs#get-apiv2items)

Qiita/src/pages/home/home.html
<ion-header>
  <ion-navbar>
    <ion-title>
      <span text-color="qiita-color">Qiita</span>
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content class="masters">
  <div class="row" *ngIf="qiitaItems?.length === 0">
    <ion-card>
      <ion-card-header text-center>
        <ion-icon name="sad"></ion-icon>
      </ion-card-header>
      <ion-card-content text-center>
        一致する記事は、見つかりませんでした。  
      </ion-card-content>
    </ion-card>
  </div>

  <ion-card class="card-item-page" *ngFor="let qiitaItem of qiitaItems" >  
    <ion-item>
      <p class="item-date"><ion-icon name="calendar"></ion-icon> {{ qiitaItem.created_at }} </p>
      <ion-avatar item-end>
        <img src="{{ qiitaItem.user.profile_image_url }}">
      </ion-avatar>
    </ion-item>

    <ion-card-content>
      <h2 class="item-title">{{ qiitaItem.title }}</h2>
      <p class="item-tags">{{ getTagList(qiitaItem.tags) }}</p>
    </ion-card-content>

    <ion-row class="card-row">
      <ion-col>
        <div><ion-icon name="heart"></ion-icon> {{ qiitaItem.likes_count }}</div>
      </ion-col>
      <ion-col>
        <div><ion-icon name="chatboxes"></ion-icon> {{ qiitaItem.comments_count }}</div>
      </ion-col>
    </ion-row>
  </ion-card>

  <div class="row" *ngIf="isError === true">
    <ion-card>
      <ion-card-header text-center>
        <ion-icon name="warning" color="warning"></ion-icon>
      </ion-card-header>
      <ion-card-content text-center>
        {{ errerMessage }}
      </ion-card-content>
    </ion-card>
  </div>

</ion-content>

home.sccsで見た目を整えます。

Qiita/src/pages/home/home.sccs
page-home {
    .masters {
        background-color: #F9F9F9;
    }

    [text-color="qiita-color"] {
        color: #5FBF3C;
    }

    .card-item-page { 
        .item-date {
            font-size: 0.8em;
            position: relative;
            text-align: left;
        }

        .item-title {
            font-size: 1.3em;
            width: 100%;
            font-weight: 120%;
        }

        .item-tags {
            color: #7C7C7C;
        }

        .card-row {
            font-size: 1.0em;
            width: 100%;
            font-weight: 100%;
            color: #5FBF3C;
            text-align: center;
        }    
    }

}

記事一覧を取得する処理をhome.tsに定義します。
Qiita API で記事一覧を取得する時の検索オプションを、「user:kijibato」としていますが適宜変更してください。

Qiita/src/pages/home/home.ts
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { QiitaServiceProvider } from '../../providers/qiita-service/qiita-service';
import { QiitaItem } from '../../providers/qiita-service/QiitaItem';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  qiitaItems: QiitaItem[];
  private currentPage: number;
  private isError: boolean;
  private errerMessage: string;
  private queryOption; string;

  constructor(public navCtrl: NavController, private qiitaServiceProvider: QiitaServiceProvider) {
    this.currentPage = 1;
    this.queryOption = encodeURI("user:kijibato");
    console.log(this.queryOption);

    this.qiitaServiceProvider.getQiitaItems(this.currentPage, this.queryOption)
      .subscribe(items => {
        this.qiitaItems = items;
        console.log(items);
        this.errerMessage = '';
        this.isError = false;
      },
      err => {
        console.log(err);
        this.errerMessage = err;
        this.isError = true;
      },
      () => {});
  }

  getTagList(tags) {
    var names = tags.map(function(element, index, array) {
      return element.name;
    });
    return names.join(', ');
  }

}


変更後の動作確認。

ionic serve --lab

Qiitaの記事一覧を表示するだけのアプリの完成です。

記事一覧画面.png

機能追加

ここまでは、記事一覧を1ページ分表示するだけなので、次のページを読み込む機能などを追加します。

次ページの読み込み機能(無限スクロール)

Ionicでは、下までスクロール終わった時に次ページを読み込む時に使える、ion-infinite-scrollがあるので、それを利用します。
シンプルに、コンテンツの下側にinfinite-scrollタグを置いて、読み込み時に実行する関数を作ってあげるだけです。(変更箇所の抜粋。全体は最後に掲載してます。)

Qiita/src/pages/home/home.html
<ion-content class="masters">
...
  </ion-card>

  <div class="row" *ngIf="hasMoreData === false">
    <ion-card>
      <ion-card-content text-center>
        これ以上、記事はありません。
      </ion-card-content>
    </ion-card>
  </div>

  <div class="row" *ngIf="isError === true">
...

  <ion-infinite-scroll *ngIf="hasMoreData" (ionInfinite)="$event.waitFor(doInfinite())" threshold="15%">
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>

</ion-content>

読み込み時に実行する関数は、home.tsに。
元のqiitaItems配列に、新しく取得した配列を後ろにくっつけるようにします。

this.qiitaItems = this.qiitaItems.concat(items);
Qiita/src/pages/home/home.ts
export class HomePage {
...
  private hasMoreData: boolean;
...
  doInfinite(infiniteScroll): Promise<any> {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.currentPage += 1;

        this.qiitaServiceProvider.getQiitaItems(this.currentPage, this.queryOption)
        .subscribe(items => {
          this.qiitaItems = this.qiitaItems.concat(items);
          if (this.currentPage >= 2 && items.length == 0) {
            this.hasMoreData = false;
          }
          console.log(items);
          this.isError = false;
        },
        err => {
          console.log(err)
          this.errerMessage = err
          this.isError = true; 
        },
        () => {});

        resolve();
      }, 500);
    })
  }

}

正直そこまでスムーズではないですが、次ページが読めるようになりました。

引っ張って更新機能

一覧を表示するアプリでよくみる、下に引っ張って新しい情報を取得する機能を追加します。
Ionicでは、ion-refresherが使って実現します。
こちらもシンプルに、コンテンツの上側にion-refresherタグを置いて、更新時に実行する関数を作ってあげるだけです。

Qiita/src/pages/home/home.html
<ion-content class="masters">
  <ion-refresher (ionRefresh)="doRefresh($event)">
    <ion-refresher-content
      pullingText="ひっぱって更新"
      refreshingText="更新中...">
    </ion-refresher-content>
  </ion-refresher>

読み込み時に実行する関数は、home.tsに。
更新の時は、差分だけの取得ではないので、いさぎよく取得した配列に置き換えます。

Qiita/src/pages/home/home.ts
  doRefresh(refresher) {
    this.currentPage = 1;

    setTimeout(() => {
      this.qiitaServiceProvider.getQiitaItems(this.currentPage, this.queryOption)
        .subscribe(items => {
          this.qiitaItems = items;
          console.log(items);
        },
        err => console.log(err),
        () => {});

      refresher.complete();
    }, 500);
  }

タップして記事URLを開く

最後にカードをタップした場合に、記事を開く機能を追加です。
アプリ内で記事を開くより、アプリ内でブラウザを開くプラグインを利用した方が楽そうなので、in-app-browserを利用します。

https://ionicframework.com/docs/native/in-app-browser/
を参考に、プラグインを追加するコマンドを実行して、

ionic cordova plugin add cordova-plugin-inappbrowser
npm install --save @ionic-native/in-app-browser

app.modules.tsにも、InAppBrowserの読み込むための行を追加します。

Qiita/src/app/app.modules.ts
import { QiitaServiceProvider } from '../providers/qiita-service/qiita-service';
import { InAppBrowser } from '@ionic-native/in-app-browser';

@NgModule({

...

  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    QiitaServiceProvider,
    InAppBrowser
  ]
})
export class AppModule {}

home.htmlのカードの定義に、タップ時の処理(on-tap)にurlを渡すように変更。

Qiita/src/pages/home/home.html
  <ion-card class="card-item-page" *ngFor="let qiitaItem of qiitaItems" on-tap="onTap(qiitaItem.url)">

home.tsには、タップ時の処理に指定したonTap()の処理で、アプリ内ブラウザを開く処理を追加します。

Qiita/src/pages/home/home.ts
  onTap(externalPage) {
    var options = [
      'location=no',
      'enableViewportScale=yes',
      'transitionstyle=crossdissolve',
      'closebuttoncaption=x'
    ];
    window.open(externalPage, '_blank', options.join());
  }

ちなみに、ブラウザを開く動きは、Macのブラウザ上と、iPhone上での開き方が違います。
ブラウザ上では別ウィンドウが開きますが、スマホでは下にバーがあるブラウザ画面に移動します。

編集したファイル

今回編集したファイルは、最終的に次のようになります。

Qiita/src/pages/home/home.html
<ion-header>
  <ion-navbar>
    <ion-title>
      <span text-color="qiita-color">Qiita</span>
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content class="masters">
  <ion-refresher (ionRefresh)="doRefresh($event)">
    <ion-refresher-content
      pullingText="ひっぱって更新"
      refreshingText="更新中...">
    </ion-refresher-content>
  </ion-refresher>

  <div class="row" *ngIf="qiitaItems?.length === 0">
    <ion-card>
      <ion-card-header text-center>
        <ion-icon name="sad"></ion-icon>
      </ion-card-header>
      <ion-card-content text-center>
        一致する記事は、見つかりませんでした。  
      </ion-card-content>
    </ion-card>
  </div>

  <ion-card class="card-item-page" *ngFor="let qiitaItem of qiitaItems" on-tap="onTap(qiitaItem.url)">  
    <ion-item>
      <p class="item-date"><ion-icon name="calendar"></ion-icon> {{ qiitaItem.created_at }} </p>
      <ion-avatar item-end>
        <img src="{{ qiitaItem.user.profile_image_url }}">
      </ion-avatar>
    </ion-item>

    <ion-card-content>
      <h2 class="item-title">{{ qiitaItem.title }}</h2>
      <p class="item-tags">{{ getTagList(qiitaItem.tags) }}</p>
    </ion-card-content>

    <ion-row class="card-row">
      <ion-col>
        <div><ion-icon name="heart"></ion-icon> {{ qiitaItem.likes_count }}</div>
      </ion-col>
      <ion-col>
        <div><ion-icon name="chatboxes"></ion-icon> {{ qiitaItem.comments_count }}</div>
      </ion-col>
    </ion-row>
  </ion-card>

  <div class="row" *ngIf="hasMoreData === false">
    <ion-card>
      <ion-card-content text-center>
        これ以上、記事はありません。
      </ion-card-content>
    </ion-card>
  </div>

  <div class="row" *ngIf="isError === true">
    <ion-card>
      <ion-card-header text-center>
        <ion-icon name="warning" color="warning"></ion-icon>
      </ion-card-header>
      <ion-card-content text-center>
        {{ errerMessage }}
      </ion-card-content>
    </ion-card>
  </div>

  <ion-infinite-scroll *ngIf="hasMoreData" (ionInfinite)="$event.waitFor(doInfinite())" threshold="15%">
    <ion-infinite-scroll-content></ion-infinite-scroll-content>
  </ion-infinite-scroll>

</ion-content>

Qiita/src/pages/home/home.sccs
page-home {
    .masters {
        background-color: #F9F9F9;
    }

    [text-color="qiita-color"] {
        color: #5FBF3C;
    }

    .card-item-page { 
        .item-date {
            font-size: 0.8em;
            position: relative;
            text-align: left;
        }

        .item-title {
            font-size: 1.3em;
            width: 100%;
            font-weight: 120%;
        }

        .item-tags {
            color: #7C7C7C;
        }

        .card-row {
            font-size: 1.0em;
            width: 100%;
            font-weight: 100%;
            color: #5FBF3C;
            text-align: center;
        }    
    }

}

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { QiitaServiceProvider } from '../../providers/qiita-service/qiita-service';
import { QiitaItem } from '../../providers/qiita-service/QiitaItem';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  qiitaItems: QiitaItem[];
  private currentPage: number;
  private isError: boolean;
  private errerMessage: string;
  private queryOption; string;
  private hasMoreData: boolean;

  constructor(public navCtrl: NavController, private qiitaServiceProvider: QiitaServiceProvider) {
    this.currentPage = 1;
    this.hasMoreData = true;
    this.queryOption = encodeURI("user:kijibato");
    console.log(this.queryOption);

    this.qiitaServiceProvider.getQiitaItems(this.currentPage, this.queryOption)
      .subscribe(items => {
        this.qiitaItems = items;
        console.log(items);
        this.errerMessage = '';
        this.isError = false;
      },
      err => {
        console.log(err);
        this.errerMessage = err;
        this.isError = true; ;
      },
      () => {});
  }

  getTagList(tags) {
    var names = tags.map(function(element, index, array) {
      return element.name;
    });
    return names.join(', ');
  }

  doRefresh(refresher) {
    this.currentPage = 1;

    setTimeout(() => {
      this.qiitaServiceProvider.getQiitaItems(this.currentPage, this.queryOption)
        .subscribe(items => {
          this.qiitaItems = items;
          console.log(items);
        },
        err => console.log(err),
        () => {});

      refresher.complete();
    }, 500);
  }

  doInfinite(infiniteScroll): Promise<any> {
    return new Promise((resolve) => {
      setTimeout(() => {
        this.currentPage += 1;

        this.qiitaServiceProvider.getQiitaItems(this.currentPage, this.queryOption)
        .subscribe(items => {
          this.qiitaItems = this.qiitaItems.concat(items);
          if (this.currentPage >= 2 && items.length == 0) {
            this.hasMoreData = false;
          }
          console.log(items);
          this.isError = false;
        },
        err => {
          console.log(err)
          this.errerMessage = err
          this.isError = true; 
        },
        () => {});

        resolve();
      }, 500);
    })
  }

  onTap(externalPage) {
    var options = [
      'location=no',
      'enableViewportScale=yes',
      'transitionstyle=crossdissolve',
      'closebuttoncaption=x'
    ];
    window.open(externalPage, '_blank', options.join());
  }

}

Qiita/src/providers/qiita-service/qiita-service.ts
import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import 'rxjs/add/operator/map';
import { Observable } from 'rxjs/Rx';
import { QiitaItem } from './QiitaItem';
import 'rxjs/add/observable/throw';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/catch';

@Injectable()
export class QiitaServiceProvider {

  constructor(public http: Http) {
  }

  private _serverError(err: any) {
    console.log('sever error:', err);
    if(err instanceof Response) {
      return Observable.throw(err.json().message || 'データ取得エラー');
    }

    return Observable.throw(err || 'データ取得エラー');
  }

  getQiitaItems(page, queryOption): Observable<QiitaItem[]>{
    var url:string = '';
    let perPage = 10;

    url = "https://qiita.com/api/v2/items?page=" + page + "&per_page=" + perPage;
    if (queryOption != null &&  queryOption != '') {
      url += ("&query=" + queryOption);
    }

    return this.http.get(url)
      .map(res => <Array<QiitaItem>>res.json())
      .do(data => console.log('server data:', data))
      .catch(this._serverError);
  }

}

Qiita/src/providers/qiita-service/QiitaItem.ts
export interface QiitaItem {

}

Qiita/src/app/app.modules.ts
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';

import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
import { HttpModule } from '@angular/http';
import { QiitaServiceProvider } from '../providers/qiita-service/qiita-service';
import { InAppBrowser } from '@ionic-native/in-app-browser';

@NgModule({
  declarations: [
    MyApp,
    HomePage
  ],
  imports: [
    BrowserModule,
    HttpModule,
    IonicModule.forRoot(MyApp)
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    HomePage
  ],
  providers: [
    StatusBar,
    SplashScreen,
    {provide: ErrorHandler, useClass: IonicErrorHandler},
    QiitaServiceProvider,
    InAppBrowser
  ]
})
export class AppModule {}

参考