react-native-webを使ったみた

github.com

アプリとWebで一部同じコンポーネントを使いたくなったので使ってみた

導入

yarn add  react-native-web

↑実行する

こんな感じでweb使える

Welcome.js

import React from "react";
import { Text, View } from "react-native";

export default () => {
  return (
    <View>
      <Text>Welcome</Text>
    </View>
  );
};

react-native-webをインストールするとWeb上でreact-nativeコンポーネントを使える

さらに上記のコンポーネントは、Webで使用できるコンポーネントなので以下のようにwebの要素と一緒に使用できる

import React from "react";
import Welcome from "./Welcome";

export default () => {
  return (
    <div>
      ようこそ
      <br/>
      <Welcome />
    </div>
  );
};

実際に使ってみた

まだコードの共有化はしていないこんな感じになった

github.com

アプリ

f:id:wheatandcat:20190313083852j:plain:w300

Web

f:id:wheatandcat:20190313084102p:plain:w300

Setting onMessage on a WebView overrides existing values of window.postMessage, but a previous value was defined.

今日はハマったバグの話、忘れた頃に同じことで引っかかりそうなので、ここに残しておきます

内容

React Nativeで画面で複数のWebViewに対してonMessageで値のやり取りをしていたら、↓のエラーが発生

Setting onMessage on a WebView overrides existing values of window.postMessage,
but a previous value was defined.

ググったら、↓がヒット

github.com

対応コード

issuesの通り↓こんな感じで回避できた

import React, { Component } from "react";
import { Platform, Alert, WebView } from "react-native";
import Page from "./Page";

// issues: https://github.com/facebook/react-native/issues/10865
const patchPostMessageFunction = () => {
  var originalPostMessage = window.postMessage;

  var patchedPostMessage = function(message, targetOrigin, transfer) { 
    originalPostMessage(message, targetOrigin, transfer);
  };

  patchedPostMessage.toString = function() { 
    return String(Object.hasOwnProperty).replace('hasOwnProperty', 'postMessage');
  };

  window.postMessage = patchedPostMessage;
};

const patchPostMessageJsCode = `(${String(patchPostMessageFunction)})();`;


export default class extends Component {
  onMessage = event => {
    const message = event.nativeEvent.data;
    const result = JSON.parse(message);

    if (!result.message) {
      return;
    }

    this.props.onChange({
       ...result
    });
  };

  render() {
    return (
          <WebView
            source={{
              uri: this.props.uri
            }}
            style={{ height: "100%" , width: "100%" }}
            onMessage={this.onMessage}
            injectedJavaScript={patchPostMessageJsCode}
          />
    );
  }
}

とりあえず解決!

react-native-swipeoutを使ってみた

リストから削除したいなーと思いパッケージを探したら、いい感じのパッケージが見つかった

react-native-swipeout

github.com

アプリではよくあるUIで、アイテムを横にスワイプすると削除ボタンがでてくるやつ

サンプルコード

import Swipeout from 'react-native-swipeout';

// Buttons
var swipeoutBtns = [
  {
    text: 'Button'
  }
]

// Swipeout component
<Swipeout right={swipeoutBtns}>
  <View>
    <Text>Swipe me left</Text>
  </View>
</Swipeout>

どうやら、アイテムに対してラップすれば、いいっぽい

実装

今回のアプリだと↓のところでスワイプして削除ボタンを表示させる感じ

f:id:wheatandcat:20190225235649p:plain:w300

コーディング

こんな感じでできた。めっちゃ簡単

import React, { Component } from "react";
import {
  View,
  Text,
  FlatList,
  RefreshControl,
  TouchableOpacity,
  Alert
} from "react-native";
import Swipeout from "react-native-swipeout";
import Card, { ItemProps as CardProps } from "../../molecules/Home/Card";

export interface Props {
  data: CardProps[];
  loading: boolean;
  onSchedule: (id: string, title: string) => void;
  onDelete: (id: string) => void;
}

export default class extends Component<Props> {
  renderItem({ item }: { item: CardProps }) {
    var swipeoutBtns = [
      {
        backgroundColor: "#fff",
        component: (
          <DeleteButton
            onPress={() => {
              Alert.alert(
                "削除しますか?",
                "",
                [
                  {
                    text: "キャンセル",
                    style: "cancel"
                  },
                  {
                    text: "削除する",
                    onPress: () => {
                      this.props.onDelete(item.id);
                    }
                  }
                ],
                { cancelable: false }
              );
            }}
          />
        )
      }
    ];

    return (
      <Swipeout right={swipeoutBtns} backgroundColor="#fff">
        <Card {...item} onPress={this.props.onSchedule} />
      </Swipeout>
    );
  }

  render() {
    return (
      <View>
        <FlatList
          refreshControl={<RefreshControl refreshing={this.props.loading} />}
          refreshing={this.props.loading}
          data={this.props.data}
          keyExtractor={item => String(item.id)}
          renderItem={this.renderItem.bind(this)}
          contentContainerStyle={{ paddingBottom: 300 }}
        />
      </View>
    );
  }
}

interface DeleteButtonProps {
  onPress: () => void;
}

const DeleteButton = (props: DeleteButtonProps) => (
  <TouchableOpacity onPress={props.onPress}>
    <View
      style={{
        margin: 3,
        borderRadius: 5,
        borderWidth: 0.5,
        borderColor: "red",
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "red",
        height: 80
      }}
    >
      <Text style={{ color: "#fff", fontWeight: "bold" }}>削除</Text>
    </View>
  </TouchableOpacity>
);

動作

満足した

f:id:wheatandcat:20190226000045g:plain:w300

go言語でCloud Functions + FireStoreをhttpで使ってみた

goのCloud FunctionsがBetaじゃなくなったので、使ってみた 一般的にはhttpよりもGCSとかPUB/SUB周りで使用するイメージですが、今回はhttpでサンプルを作っていきます。(httpならGAEでも良かったけど。。。)

まずはチュートリアル

※gcloudの設定周りはできている前提

以下のファイル作成

■ hello_world.go

package helloworld

import (
    "fmt"
    "net/http"
)

// HelloGet is an HTTP Cloud Function.
func HelloGet(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello, World!")
}

で、コマンド実行

$gcloud functions deploy HelloGet --runtime go111 --trigger-http

で確認

$curl  https://us-central1-プロジェクトID.cloudfunctions.net/HelloGet
Hello, World!

デプロイできました

FireStoreを使ってみる

firebase.google.com

Firebaseを導入

$go get firebase.google.com/go

Firebaseのクライアントを取得

  • Firebaseのクライアントはサービスアカウントの鍵を生成して認証

■firestore.go

package app

import (
    "context"

    "cloud.google.com/go/firestore"
    firebase "firebase.google.com/go"
    "google.golang.org/api/option"
)

func getFireBaseClient(ctx context.Context) (*firestore.Client, error) {
    opt := option.WithCredentialsFile("serviceAccountKey.json")
    app, err := firebase.NewApp(ctx, nil, opt)
    if err != nil {
        return nil, err
    }

    return app.Firestore(ctx)
}

データを保存

Collectionを「results」に設定 ドキュメントにUUIDを設定して、そこに「item」を設定する

package app

import (
    "context"
    "encoding/json"
    "net/http"
    "time"

    "github.com/rs/xid"
)

func SaveItem(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()

    client, err := getFireBaseClient(ctx)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    guid := xid.New()
    item := Item{
        Status: "OK",
        Day:    time.Now().In(jst).Format("2006-01-02"),
    }
    _, err = client.Collection("results").Doc(guid.String()).Set(ctx, item)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    res, err := json.Marshal(item)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Write(res)
}

データを取得

Collectionを「results」に設定してWhereで「day」が今日の日付になっているデータを取得

package app

import (
    "context"
    "encoding/json"
    "net/http"
    "time"
)

func GetItem(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()

    client, err := getFireBaseClient(ctx)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    jst, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    day := time.Now().In(jst).Format("2006-01-02")

    matchItem := client.Collection("results").Where("day", "==", day).Documents(ctx)
    docs, err := matchItem.GetAll()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    data := []interface{}{}
    for _, doc := range docs {
        data = append(data, doc.Data())
    }

    w.Header().Set("Content-Type", "application/json")
    res, err := json.Marshal(data)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Write(res)
}

デプロイ

$gcloud functions deploy SaveItem --runtime go111 --trigger-http
$gcloud functions deploy GetItem --runtime go111 --trigger-http

これで完成

github

その内、自分でも使いそうなので忘れないようにリポジトリを作っておきました。

github.com

最後

GAEで良いような気もするけど、たまにバージョンを作りすぎてデプロイできなくなるから、実験用のAPIはCloud Functionsでも良いかも

react-nativeでe2eを実装してみる

シェアフル Advent Calendar 2018 17日目の記事です。

ある程度アプリの機能が揃ってきたので、そろそろテスト追加フェーズかなという感じでe2eの実装してみた

react-nativeでe2eの実装

react-nativeではDetoxを使ってe2eを実装していきます

github.com

また↓を使えばexpoでもe2eが可能です

GitHub - expo/detox-expo-helpers

detox-tools/packages/expo-detox-hook at master · expo/detox-tools · GitHub

Detox実装

事前準備

ここにある通りでOK

Detox/Introduction.GettingStarted.md at master · wix/Detox · GitHub

DEMOを動かしてみよう

こちらが、動かせるサンプルです

GitHub - expo/with-detox-tests: This template is currently out of date! If you'd like to maintain it, reach out to brent@expo.io

READMEの通りにセットアップすれば、こんな感じに動きます

https://raw.githubusercontent.com/expo/with-detox-tests/master/example.gif

早速アプリ組み込んでみる

インストール & 設定

mochaとjestどちらでも使えますが、ここではDEMOと同じでmochaを選択

npm i -D detox detox-expo-helpers expo-detox-hook mocha

初期テストコードをcliから追加

npm install -g detox-cli
detox init -r mocha

package.jsonにdetoxの設定を追加

"detox": {
  "configurations": {
    "ios.sim": {
      "binaryPath": "bin/Exponent.app",
      "type": "ios.simulator",
      "name": "iPhone 7"
    }
  }
}

package.jsonにe2eのコマンドを追加

  "scripts": {
    "e2e": "detox test --configuration ios.sim"

これで初期設定はOK

テストコード

テストの書き方

アプリ側に 「testID」を埋め込んでテストコード側で、そこを対象に操作していきます

テキストの判定

■アプリ

<View>
   <Text>プランの登録はありません</Text>
</View>

■テストコード

await expect(element(by.label("プランの登録はありません"))).toBeVisible();

ボタンをタップする

■アプリ

<TouchableOpacity onPress={this.props.onCreate} testID="addSchedule">
   <Ionicons name="ios-add-circle" size={80} color="#4DB6AC" />
</TouchableOpacity>

■テストコード

await element(by.id("addSchedule")).tap();

テキストを入力する

■アプリ

<TextInput
    placeholder="タイトルを入力"
    placeholderTextColor="#ffffff"
    onChangeText={title => this.setState({ title })}
    defaultValue={this.props.title}
    testID="inputTextScheduleDetailTitle"
/>

■テストコード

await element(by.id("inputTextScheduleDetailTitle")).replaceText("新宿駅");

大体、これくらい覚えておけばOK

実装

スケジュール登録を一通り操作してスクリーンショットを取る形式で実装したら、こんな感じになった

https://github.com/wheatandcat/Shiori/blob/master/ShioriNative/e2e/firstTest.spec.js

■ShioriNative/e2e/firstTest.spec.js

const { reloadApp } = require("detox-expo-helpers");
const { takeScreenshot } = require("./helpers");

describe("Example", () => {
  beforeEach(async () => {
    await reloadApp();
  });

  afterEach(async () => {
    takeScreenshot();
  });

  it("初期表示", async () => {
    await expect(element(by.label("プランの登録はありません"))).toBeVisible();
  });

  it("スケジュール追加", async () => {
    await element(by.id("addSchedule")).tap();
    await element(by.id("inputTextTitle")).tap();
    await element(by.id("inputTextTitle")).replaceText("葛西臨海公園");
    await element(by.id("completion")).tap();
    takeScreenshot();

    await element(by.id("addScheduleDetail")).tap();
    takeScreenshot();
    await element(by.id("inputTextScheduleDetailTitle")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).replaceText("新宿駅");
    await element(by.id("inputTextScheduleDetailMemo")).tap();
    await element(by.id("inputTextScheduleDetailMemo")).replaceText(
      "8:00に西口に集合する"
    );
    takeScreenshot();
    await element(by.id("saveScheduleDetail")).tap();

    await element(by.id("addScheduleDetail")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).replaceText(
      "葛西臨海公園"
    );
    await element(by.id("inputTextScheduleDetailMemo")).tap();
    await element(by.id("inputTextScheduleDetailMemo")).replaceText(
      "行く場所:砂浜、観覧車、水族園"
    );
    await element(by.id("saveScheduleDetail")).tap();

    await element(by.id("addScheduleDetail")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).replaceText(
      "葛西臨海公園水上バス"
    );
    await element(by.id("saveScheduleDetail")).tap();

    await element(by.id("addScheduleDetail")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).tap();
    await element(by.id("inputTextScheduleDetailTitle")).replaceText(
      "浅草寺二天門前"
    );
    await element(by.id("saveScheduleDetail")).tap();
    takeScreenshot();

    await element(by.id("saveSchedule")).tap();
  });
});

e2e実行

以下のコマンドで実行される

npm start
npmrun e2e

こんな感じで動作します

f:id:wheatandcat:20181217015240g:plain

実装してみた感想

ポジティブ

  • 予想以上に手頃に実装できる
  • 「testID」はキモいと思っていたが、完全にテスト用と割り切ってしまえば意外と気にならない
  • スタイル崩れチェックぐらいならスクリーンショットのチェックだけでもOKかも

ネガティブ

  • testIDに対応していないコンポーネントの対応ができない。ActionSheetの操作が出来ないのが痛い
  • expoだとandroidで実行ができない

ciで実行できるのかな?その辺が、まだよく分かってなので、もう少し調べる予定

react-nativeでsortableなUIを実装

シェアフル Advent Calendar 2018 12日目の記事です。

webならドラッグ & ドロップでアイテム順番を入れ替えるけど、react-nativeならどうするのかなー と思いググったらreact-nativeでsortable listを実装しているライブラリがあったので、今回はそのライブラリを紹介します

sortableとは

js.studio-kingdom.com

ドラッグ & ドロップで要素を入れ替えるUIを指します。 順番入れ替えを実装する際に利用されるUIで、直感的な操作を提供できます

react-nativeでの実装

調べるといくつかライブラリが出てきますが、今回は以下のライブラリで実装してみた

github.com

選定理由は以下の通り

実装してみた

現状、こんな感じ

f:id:wheatandcat:20181212094406g:plain

並び替えに行くまでの導線が長いので、まだまだ改善の余地はあるけど、実装したかったことはできたので一旦良しとしよう

コード

ほぼExampleのコピペですが、大体こんな感じで動くコードが書けた。

https://github.com/wheatandcat/Shiori/blob/master/ShioriNative/src/components/organisms/SortableSchedule

■ Cards.tsx

import React, { Component } from "react";
import SortableList from "react-native-sortable-list";
import getKind from "../../../lib/getKind";
import Card from "../../molecules/Schedule/Card";
import Row from "./Row";

type DataKey = string | number;

export interface ItemProps {
  id: string;
  title: string;
  moveMinutes: number | null;
}

export interface Props {
  data: ItemProps[];
  onChange: (data: any) => void;
}

export interface RowProps {
  data: ItemProps;
  active: boolean;
}

export default class extends Component<Props> {
  renderItem({ data, active }: { data: ItemProps; active: boolean }) {
    return (
      <Row active={active}>
        <Card id={data.id} title={data.title} kind={getKind(data.title)} />
      </Row>
    );
  }

  onChange = (nextOrder: DataKey[]) => {
    const data = nextOrder.map(id => {
      return this.props.data.find(item => Number(item.id) === Number(id));
    });

    this.props.onChange(data);
  };

  render() {
    const obj = this.props.data.reduce((o, c) => ({ ...o, [c.id]: c }), {});

    return (
      <SortableList
        data={obj}
        renderRow={this.renderItem.bind(this)}
        style={{ flex: 1 }}
        onChangeOrder={this.onChange}
      />
    );
  }
}

■ Row.tsx

import React, { Component } from "react";
import { Animated, Easing, Platform, View } from "react-native";

export interface RowProps {
  active: boolean;
  children: any;
}

export default class extends Component<RowProps> {
  _active: any;
  _style: any;

  constructor(props: RowProps) {
    super(props);

    this._active = new Animated.Value(0);

    this._style = {
      ...Platform.select({
        ios: {
          transform: [
            {
              scale: this._active.interpolate({
                inputRange: [0, 1],
                outputRange: [1, 1.1]
              })
            }
          ],
          shadowRadius: this._active.interpolate({
            inputRange: [0, 1],
            outputRange: [2, 10]
          })
        },

        android: {
          transform: [
            {
              scale: this._active.interpolate({
                inputRange: [0, 1],
                outputRange: [1, 1.07]
              })
            }
          ],
          elevation: this._active.interpolate({
            inputRange: [0, 1],
            outputRange: [2, 6]
          })
        }
      })
    };
  }

  componentDidUpdate(prevProps: RowProps) {
    if (this.props.active !== prevProps.active) {
      Animated.timing(this._active, {
        duration: 300,
        easing: Easing.bounce,
        toValue: Number(this.props.active)
      }).start();
    }
  }

  render() {
    return (
      <Animated.View style={[this._style]}>
        <View style={{ paddingBottom: 50 }}>{this.props.children}</View>
      </Animated.View>
    );
  }
}

最後に

ドラッグ & ドロップはreact-dndという魔境ライブラリの思い出があったので手を出していなかったけど、「react-native-sortable-list」は使い方が限定されているので、実装しやすかった。

github.com

開発者視点で一年間Atomic Designと付き合ってみた結果

シェアフル Advent Calendar 2018 3日目の記事です。

ここ一年くらい複数のプロジェクトでAtomic Designを取り入れてやっていたので、その辺の話を開発者視点で書いていこうと思います。

Atomic Designとは

bradfrost.com

解説については↑のサイトを参照

コンポーネントの種類

Atomic Designでは以下の種類のコンポーネントに分けて実装

  • Atoms
  • Molecules
  • Organisms
  • Templates
  • Pages

個人開発プロジェクトで採用しているので実装例を元に各種類について解説します

Atoms

最小単位のパーツコンポーネント 「テキスト」、「画像」、「テキスト入力」、「チェックボックス」、「セレクトボックス」等々が該当します

f:id:wheatandcat:20181203012436p:plain:w400

Molecules

Atomsを2つ以上組み合わせたコンポーネント

f:id:wheatandcat:20181203013444p:plain:w300

f:id:wheatandcat:20181203013459p:plain:w300

f:id:wheatandcat:20181203013516p:plain:w300

Organisms

MoleculeとAtomsを組み合わせて1機能として自己完結しているコンポーネント

f:id:wheatandcat:20181203013838p:plain:w300

MoleculeとOrganismsの違いについては下記の記事を読むと分かりやすいです。 frasco.io

Templates

Organisms、Molecule、Atomsを組み合わた1画面のコンポーネント

f:id:wheatandcat:20181203015600p:plain:w300

Pages

Templatesに実際の情報を組み込んだ最終的なコンポーネント

f:id:wheatandcat:20181203015106p:plain:w300

開発での実装

採用しているプロジェクト情報

github.com

  • react-native (expo)で実装

フォルダ構造

  • src/components/ 以下に、「atoms」〜「 pages」のフォルダを作成し該当するコンポーネントを作成している

f:id:wheatandcat:20181203020438p:plain:w700

Shiori/ShioriNative/src/components at master · wheatandcat/Shiori · GitHub

storybookとの連携

storybookはコンポーネント単位の表示を管理ツールできるツールです storybookの詳しい情報は下記参照

github.com

Atomic Designに合わせてstorybookファイルを作成しコンポーネントを管理しています

f:id:wheatandcat:20181203021936p:plain:w700

Atomic Designを採用してみての話

明確なメリット

問題点

理解力の差によって配置されるべきコンポーネントに差異が起きる

  • 上記にもリンクを貼っているがMoleculeOrganismsは特に誤った配置がされやすいので注意が必要

十分に注意してコード修正、レビューを行わないと負債になりやすい

  • リファクタリングの際にコンポーネントの役割が「Atoms→Molecule」や「Molecule→Organisms」に変更されたが、フォルダの移動がされずに放置され負債になるケースが起きやすい
  • 上記の負債はあくまで「Atomic Design」での誤りなので、もちろんテストコードでは分からず、またコードレビューのみでは分かりづらい。なので定期的なstorybookでのチェック and リファクタリングコストをかける必要がある

Atomsにするか個々のコンポーネントに書いてしまうか悩む

  • 複数箇所で使用されるコンポーネントならAtomsで定義する価値があるけど、将来的にどの程度使われるか分からないコンポーネントをAtomsでわざわざ定義する必要があるのか悩む

まとめ

  • 一年間かけて徐々に理解力が深まっている感じはする
  • 問題点はあるが、それでも何も基準無しに作るのよりは大きくマシだと言えると思う
  • コードレビューだけだと間違いを見逃すからstorybookは必須だと思う(もちろん、理想は自動テストだけど。。。)

実際のコンポーネントを確認したい人は

  • 以下でstorybookのアプリを公開しているのでexpoからご確認ください 🙇

expo.io

もろもろの参考URL