ReactNative で Android の back ボタンを押したときの挙動を HOC で制御する。(テストもあるよ)


RN で Android の back ボタンを押したとき、標準ライブラリの BackAndroid を使って制御するが、一部コンポーネントのみで BackAndroid を使いたいときには

1. Component が使われるときにイベント登録する
2. Component が使われないときにイベント解除する

という一連の流れを毎度書く事になって面倒くさい、ので HOC(Higher-Order Components) で実装することで

```js
@handleBackButton
class BackButtonHandlingComponent extends Component {
  backButtonPressed():boolean {
    return doSomething();
  }
  render() {
    return <View />;
  }
}
```

みたいに書きたい。

なお同じ事が出来るライブラリ

- https://github.com/AwesomeJerry/react-native-back-android

があるが、シンプルに HOC で単機能のみを実装したかったので自前で実装した。

# 実装する

HOC については

- [2016年7月15日
JavaScriptReact
ReactのHigher Order Components詳解 : 実装の2つのパターンと、親Componentとの比較](http://postd.cc/react-higher-order-components-in-depth/)

が詳しい。今回は HOC でラップするコンポーネントの実装には `backButtonPressed():boolean` が実装されて欲しかったので

- [Flowで型を指定しながらHOCを書く](http://qiita.com/yuku_t/items/a9821ce0a2340b350004)

で解説されてるとおり、`Class<Interface> & ReactClass<any>` の flowtype をつけた。

```
interface BackButtonPressed {
  backButtonPressed(): boolean,
}

type BackButtonPressedComponent = Class<BackButtonPressed> & ReactClass<any>;
```

これにより、`backButtonPressed():boolean` が実装されてなかった場合、

```
 10: type BackButtonPressedComponent = Class<BackButtonPressed> & ReactClass<any>;
                                             ^^^^^^^^^^^^^^^^^
    property `backButtonPressed` of BackButtonPressed. Property not found in
 28:     const BackButtonHandlingComponent = handleBackButton(BackButton);
                                                              ^^^^^^^^^^
                                                              BackButton.
    See: __tests__/react/hoc/handleBackButton.js:28
```

みたいな型エラーになる。

(が残念ながら flow v0.40 では decorators での指定 `@handleBackButton(ReactComponent)` では型エラーにならず...)

## 実装コード

特に難しいことはやっておらず、mount / unmount 時に登録・解除処理を書いているだけ。また、ios か android かで切り分け、ラップするかどうかを決めている。

```js
// @flow

import { BackAndroid, Platform } from 'react-native';
import React, { Component } from 'react';

interface BackButtonPressed {
  backButtonPressed(): boolean,
}

type BackButtonPressedComponent = Class<BackButtonPressed> & ReactClass<any>;

export default function handleBackButton(
  WrappedComponent: BackButtonPressedComponent,
): ReactClass<any> {
  class BackButtonComponent extends Component {
    componentDidMount() {
      const { backButtonPressed } = this.targetComponentRef;
      this.backButtonPressedSubscription = BackAndroid.addEventListener(
        'backPress',
        backButtonPressed,
      );
    }

    componentWillUnmount() {
      if (this.backButtonPressedSubscription) {
        this.backButtonPressedSubscription.remove();
      }
    }

    targetComponentRef: typeof WrappedComponent;

    backButtonPressedSubscription: ?{
      remove(): void,
    };

    render() {
      return (
        <WrappedComponent
          ref={(component: typeof WrappedComponent) => {
            this.targetComponentRef = component;
          }}
          {...this.props}
        />
      );
    }
  }

  return Platform.select({
    ios: WrappedComponent,
    android: BackButtonComponent,
  });
}
```

HOC はテストも基本的には書きやすい。

今回はちゃんとイベント登録、解除がされているかと、Android と iOS での挙動の差異のチェックをするテストを書いた。

RN のライブラリは基本 jest.mock() でモックか出来るので書きやすい。

```js
// @flow
/* eslint react/no-multi-comp: 0 */

import { View } from 'react-native';
import React, { Component } from 'react';

import renderer from 'react-test-renderer';

import handleBackButton from '../../../src/shared/hoc/handleBackButton';

function createHOC() {
  const remove = jest.fn();
  const mockListener = jest.fn(() => ({
    remove,
  }));
  jest.mock('BackAndroid', () => ({ addEventListener: mockListener }));

  @handleBackButton class BackButton extends Component {
    backButtonPressed() {
      return !!this;
    }
    render() {
      return <View />;
    }
  }
  const component = renderer.create(<BackButton />);
  return { component, remove, mockListener };
}

describe('Platform is android', () => {
  beforeEach(() => {
    jest.mock('Platform', () => ({ select: ({ android }) => android }));
  });

  test('backButtonPressed() is defined', () => {
    const { component, remove, mockListener } = createHOC();
    component.unmount();
    expect(mockListener).toBeCalled();
    expect(remove).toBeCalled();
  });
});

describe('Platform is ios', () => {
  beforeEach(() => {
    jest.mock('Platform', () => ({ select: ({ ios }) => ios }));
  });

  test('backButtonPressed() is defined', () => {
    const { component, remove, mockListener } = createHOC();
    component.unmount();
    expect(mockListener).not.toBeCalled();
    expect(remove).not.toBeCalled();
  });
});
```

って、この記事書いてから気付いたけど、RN 0.44 (2017年5月時点の最新版) で [BackAndroid が  Deprecated](https://facebook.github.io/react-native/docs/backandroid.html) になってた… (単純な BackHandler との置換で置き換えられるけど)