Tinder風UIで「好き」「嫌い」を投票できる画面を実装する【React×Railsアプリ開発 第8回】

#やったこと
* Tinder風UIで投稿に対して、「好き」か「嫌い」が投票できる画面を実装した。
* モジュールは[react-swipe-card-chsstm](https://www.npmjs.com/package/react-swipe-card-chsstm)を使って、Tinder風UIを実装した。

#今回の成果
![v1n1g-k17jj.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/367226/0a786baa-8dfb-c52a-b708-c0c1b3f648ee.gif)


#できなかったこと
* react-swipe-card-chsstmのコードを修正して使いたかったが、どうしても修正後のモジュールが上手くインストールできなかった。
* Github上でForkしたあとにコード修正、再度インストールまでは良かったが、トランスパイル（コンパイル）が上手くいってないのかな〜...

#実装手順
###モジュールインストール
ここでインストール済み。npm install react-swipe-card-chsstmで行けるはず。

###Rails APIの調整
* ログイン中ユーザーが「好き」か「嫌い」か投票していないポストをランダムで10個ずつ返すAPIを作る
* answered_posts_idでログイン中ユーザーが「投票した」ポストのidを呼び出している。
* Posw.where.not("id ~ で、投票したpost_id以外のidのポストの中から10個をランダムで返している。

**users_controllerにnot_answered_postsを追加する**

```ruby:users_controller.rb
      def not_answered_posts
        @user = current_api_v1_user
        answered_posts_id = "SELECT post_id from likes WHERE user_id = :user_id"
        not_answered_posts = Post.where.not("id IN (#{answered_posts_id})", user_id: @user.id).order('RANDOM()').limit(10)
        json_data = {
          'posts': not_answered_posts,
        }
        
        render json: { status: 'SUCCESS', message: 'Loaded the not_answered_posts', data: json_data}
      end 
```
###React
**Home.js**

* CardのonSwipeLeft、onSwipeRightで左右にスワイプ時に呼び出す関数を指定している。
* submitSuki, submitKiraiで「好き」「嫌い」を投票している。
* 再読み込みボタンで、リロードし、追加のnot_answered_posts（ログイン中のユーザーがまだ投票していないポスト）を10個ずつ読み込んでいる。

```javascript:Home.js
import React from 'react';
import PropTypes from 'prop-types';
import './HomeStyles.css'

import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from '../actions';
import queryString from 'query-string';
import _ from 'lodash';
import axios from 'axios';

import Cards, { Card } from 'react-swipe-card-chsstm';

import "normalize.css";
//import "./styles.css";

const styles = theme => ({

  // ヘッダーロゴ
  homeimg: {
    height: '20%',
    width: '60%',
    display: 'block',
    margin: 'auto',
  },
  conceptimg: {
    display: 'flex',
    width: '80%',
    display: 'block',
    margin: '10px auto',
  },
  button: {
    margin: '0px 5px',
  },
  sukibutton: {
    margin: '0px 15px',
    backgroudColor: '#000000',
  },
  kiraibutton: {
    margin: '0px 15px',
    backgroudColor: '#ffffff'
  },
});

class Home extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      not_answered_posts: []
    }
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    //新着順
    axios.get(process.env.REACT_APP_API_URL + `/api/v1/not_answered_posts`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const data = response.data.data;
        this.setState({
          not_answered_posts: data.posts,
        });
      })
      .catch(() => {

      });
    this.reload = this.reload.bind(this);
  }

  reset = () => {
    this.setState(state => ({
      id: state.id + 1
    }));
  };


  componentDidMount() {
  }


  submitSuki(post) {
    const { CurrentUserReducer } = this.props
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    const data = {
      user_id: CurrentUserReducer.items.id,
      post_id: post.id,
      suki: 1,
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => { })
      .catch(error => { })
  }

  submitKirai(post) {
    const { CurrentUserReducer } = this.props
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid

    const data = {
      user_id: CurrentUserReducer.items.id,
      post_id: post.id,
      suki: 0,
    }
    axios.post(process.env.REACT_APP_API_URL + '/api/v1/likes', data, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then(response => { })
      .catch(error => { })
  }

  reload() {
    const auth_token = localStorage.auth_token
    const client_id = localStorage.client_id
    const uid = localStorage.uid
    //新着順
    axios.get(process.env.REACT_APP_API_URL + `/api/v1/not_answered_posts`, {
      headers: {
        'access-token': auth_token,
        'client': client_id,
        'uid': uid
      }
    })
      .then((response) => {
        const data = response.data.data;
        var not_answered_posts = this.state.not_answered_posts
        data.posts.forEach(post => {
          not_answered_posts.push(post);
        });
        this.setState({
          not_answered_posts: not_answered_posts,
        });


      })
      .catch(() => {

      });
  }

  render() {
    const { CurrentUserReducer } = this.props;
    const { classes } = this.props;
    let cards;

    return (
      <div className="home">
        <div className="background">
          <Button size="large" variant="contained" color="blue" onClick={this.reload} className={classes.sukibutton}>
            再読み込み
            </Button>
        </div>
        <Cards
          onEnd={this.endSwipe}
          className="master-root"
          likeOverlay={<h1>スキ</h1>}
          dislikeOverlay={<h1>キライ</h1>}

          ref={(ref) => cards = ref}
        >
          {this.state.not_answered_posts.map(item =>
            <Card
              onSwipeLeft={() => this.submitKirai(item)}
              onSwipeRight={() => this.submitSuki(item)}>

              <h2>{item.content}</h2>
            </Card>
          )}

        </Cards>
        <div className="buttonArea">
          <Button size="large" variant="contained" color="blue" onClick={() => { cards.dislike(); }} className={classes.sukibutton}>
            キライ
          </Button>
          <Button size="large" variant="contained" color="red" onClick={() => { cards.like(); }} className={classes.kiraibutton}>
            スキ
        </Button>
        </div>

      </div >
    )
  }
}
Home.propTypes = {
  classes: PropTypes.object.isRequired,
  post: PropTypes.object.isRequired,
};

const mapState = (state, ownProps) => ({
  CurrentUserReducer: state.CurrentUserReducer,
});
function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch),
  };
}

export default connect(mapState, mapDispatch)(
  withStyles(styles, { withTheme: true })(Home)
);
```

**HomeStyles.css**

* Home.jsのスタイルを別途HomeStyles.cssに記述しています。
* これ、結構うまくやらないとちゃんと配置してくれなくて苦労しました。Cardを10枚ずつ重ねて表示していて、z-indexを使っているから、ここで配置がずれたりする。
* ここを参考にしました。https://github.com/chsstm/react-swipe-card/blob/master/stories/style.css


```javascript:HomeStyles.css
  
html {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}

body {
  margin: 0 auto;
  padding: 0;
  font-family: sans-serif;
  text-align: center;
}

img {
  width: 100%;
}

li {
  list-style: none;
}

h2 {
  text-align: left;
  word-wrap: break-word;
  margin: 20px;
  font-size: 20pt;
}


.home {
  position: relative;
  overflow: hidden;
  width: 100%;
  height: 100%;
  min-height: 500px;
}

.master-root {
  margin: 10px 0px;
  position: absolute;
  height: 50%;
  width: 100%;
  /* z-index:2; */
}
.card {
  background-color: white;
  background-size: cover;
  position: absolute;
  left: 0;
  right:0;
  top:0;
  bottom:0;
  background: #f8f3f3;
  height: 80%;
  width: 80%;
  margin: auto;
  transition: box-shadow 0.3s;
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
  border: 10px solid #212121;
  cursor: pointer;
}

.buttonArea{
  position: absolute;
  bottom: 20%;
  width: 100%;
  margin: 0 auto;
}

.animate {
  transition: transform 0.3s;
  box-shadow: none;
}

.inactive {
  box-shadow: none;
}

.alert {
  width: 45%;
  min-height: 10%;
  position: absolute;
  z-index: 9999;
  opacity: 0;
  transition: opacity 0.5s;
  color: white;
  vertical-align: middle;
  line-height: 3rem;
}

.alert-visible {
  opacity: 1;
}

.alert-right {
  top: 0;
  right: 0;
  background: red;
  border-top-left-radius: 50px;
  border-bottom-left-radius: 50px;
}

.alert-left {
  top: 0;
  left: 0;
  background: blue;
  border-top-right-radius: 50px;
  border-bottom-right-radius: 50px;
}

.alert-top {
  background: black;
  border-radius: 50px;
  transform: translate(-50%, 0);
  margin-left: 50%;
}

.alert-bottom {
  bottom: 0;
  background: black;
  border-top-left-radius: 50px;
  border-radius: 50px;
  transform: translate(-50%, 0);
  margin-left: 50%;
}

.action-button {
  cursor: pointer;
  padding: 10px 50px;
  border: none;
  outline: none;
  background-color: lightgrey;
  margin: 0px 10px;
}

.action-button:active {
  background-color: black;
  color: white;
}
```