シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

ReactでLINE風チャット画面を実装してみた

シンクロ・フードでフロントエンドの開発を担当している四之宮です。
今回は、前回のブログで宣言した通り、ReactでLINE風チャット画面を実装してみたことについてお話ししたいと思います。

ReactとCSSがある程度わかることが前提になります。

作成した機能について

この機能は、弊社が運営している店舗デザイン.COMのサイト内で提供されるサービスになります。

www.tenpodesign.com

店舗デザイン.COMでは、「店舗の出店や改装を考える方と店舗のデザインや施工をおこなうデザイン会社とを結びつける」ということを行っています。
機能の実装をお話しする前に、簡単にではありますが店舗デザイン.COMの説明をしたいと思います。

まず、店舗の出店や改装を考える方(以後施主と表記)は、店舗のイメージや情報を登録します。
その後、この情報をデザイン会社に配信し、興味を持ったデザイン会社がエントリーをします。
施主は、エントリーがあったデザイン会社の過去の作品や情報を見て、もっと詳細な情報を知りたいということになると、実際のやりとりが開始します。

ここで、今回作成したやり取りの機能が使用されます。

実際の画面

まず最初に作成した画面をご紹介したいと思います。
こちらの画面は、施主側のスマホ版のページになります。

f:id:synchro-food:20170427190641p:plain

一覧表示について

簡単な概要は下記の通りです。

  • 画面を開くと、最新の10件が表示される
  • 最下部が最新メッセージで、上にいくほど古い
  • 続きの読み込みはボタンのタップ
  • 前後のメッセージで日付けが変わる場合は、日付を挿入する
  • 送信メッセージ
    • 表示エリアは右寄せで表示
    • 背景は白
    • 相手が読んでいれば「既読」と表示
  • 受信メッセージ
    • 表示エリアは左寄せで表示
    • 相手の名前を表示
    • 背景はグレー

ここからは、ちょっと大変だったポイントを掻い摘んで説明したいと思います。

並び順と日付け挿入について

配列には新しい順でデータを持ちますが、表示では逆順で表示する必要があります。
これは、reverse()をすれば解決です。
当たり前ですが、Immutable.jsなどを使って、propsのmessagesを直接reverse()しないように注意してください。
日付け挿入に関しては、直前のメッセージと比較して、日付が違っていたら日付けを表示するようにします。

下記は、MessageListというコンポーネントのrender部分を一部抜粋したものになります。

<div className="messageList js-messageList">
    {
        MessageList.getReverseMessages(this.props.messages).map((message, index, messages) => {
            let prevMessage = {}
            if (index !== 0) {
                prevMessage = messages[index - 1]
            }
            if (MessageList.isChangeDate(message, prevMessage)) {
                return (
                    <div className="messageList__itemWrap" key={message.keyId}>
                        <div className="messageList__item messageList__item--date">
                            {MessageList.showDate(message.createDate)}
                        </div>
                        <div className="messageList__item">
                            <Message message={message} />
                        </div>
                    </div>
                )
            }
            return (
                <div className="messageList__itemWrap" key={message.keyId}>
                    <div className="messageList__item">
                        <Message message={message} />
                    </div>
                </div>
            )
        })
    }
</div>

日付単位でulで実装したかったのですが、綺麗に日付毎表示されるわけでもないので、渋々div実装になっています。

補足

  • getReverseMessagesは引数のthis.props.messagesをreverseするメソッド
  • isChangeDateは直前のメッセージと日付けが同じか判定するメソッド
  • Messageはこの後に説明します

続きを読むなどの時にスクロール位置を保持する

続きを読みこむと、一番上に続きのメッセージが積まれていきます。
そして、読み込むためのボタンは一番上にあります。
そのため、続きを読みこむとスクロールの位置が一番上になってしまいます。
これを制御するための処理が下記になります。

下記は、MessageListというコンポーネントを一部抜粋したものになります。

constructor(props) {
    super(props)
    this.state = {
        prevHeight: 0,
    }
}

componentDidMount() {
    const height = $('.js-messageList').height()
    const heightDiff = height - this.state.prevHeight
    $('body').scrollTop(heightDiff === 0 ? $('body')[0].scrollHeight : heightDiff)
}

componentWillReceiveProps(nextProps) {
    if (nextProps.messages !== this.props.messages) {
        const prevHeight = $('.js-messageList').height()
        this.setState({ prevHeight })
    }
}

componentDidUpdate(prevProps) {
    if (prevProps.messages !== this.props.messages) {
        const height = $('.js-messageList').height()
        const heightDiff = height - this.state.prevHeight
       $('body').scrollTop(heightDiff === 0 ? $('body')[0].scrollHeight : heightDiff)
    }
}

componentWillReceivePropsを使用しているのは、メッセージの配列を管理しているが、このファイル内ではないためです。
そして、jQueryも使用しています。
賛否両論あるかと思いますが、個人的にそんなに嫌いではないので、Reactの中でも使用することがあります…。
もちろんDOM操作はしませんが。

また、新着メッセージの受信については、setTimeoutなどで受信を監視する必要があります。

受信メッセージの表示について

送受信にはそれぞれに決まったcssをあてる、日付けが変わる場合は日付を挿入するという2点を実現できればいいということです。
そして、先ほど出てきていた、Messageコンポーネントがここにあたります。
下記は、Messageというコンポーネントを一部抜粋したものになります。

const getMessageClass = (message) => {
    if (message.isSend) {
        return 'send'
    }
    return 'receive'
}

const replaceBr = text => (
    text.split('\n').map((line, index) => <p key={index}>{line}</p>)
)

return (
    <div className={`messageBox messageBox--${getMessageClass(props.message)}`}>
        <div className="messageBox__inner">
            {!props.message.isSend && <p className="messageBox__name">{props.message.name}</p>}
            {props.message.message && <p className="messageBox__message">{replaceBr(props.message.message)}</p>}
            {(() => {
                if (props.message.file) {
                    return (
                        <div className="messageBox__file">
                            添付ファイルのアイコンなどを表示する処理
                        </div>
                    )
                }
                return ''
            })()}
        </div>
        {(props.message.isAlreadyRead && props.message.isSend) && <p className="messageBox__alreadyRead">既読</p>}
    </div>
)

ここからはCSS(scss)です。
今更ではありますが、弊社ではMindBEMdingによる命名規約で、クラス名をつけています。
こちらも、必要な箇所を一部抜粋したものになります。

.messageList {
    &__itemWrap {
        &:not(:first-child) {
            margin-top: 20px;
        }
    }

    &__item {
        overflow: hidden;

        &:not(:first-child) {
            margin-top: 20px;
        }

        &--date {
            font-size: 14px;
            color: #666666;
            text-align: center;
            display: flex;
            align-items: center;
        }
    }

    &__item--date {
        // 日付けは中央で左右に線を引く
        &:before, &:after {
            border-top: 2px solid #E6E6E6;
            content: "";
            display: inline;
            flex-grow: 1;
        }

        &:before {
            margin-right: 0.5em;
        }

        &:after {
            margin-left: 0.5em;
        }
    }
}

.messageBox {
    &--send {
        float: right;
    }

    &--receive {
        float: left;
    }

    &__inner {
        border-radius: 16px;
        padding: 10px;
        box-sizing: border-box;
        display: inline-block;

        .messageBox--send & {
            background-color: #fff;
            border: solid 1px #ccc;
        }

        .messageBox--receive & {
            background-color: #f2f2f2;
        }
    }

    &__alreadyRead {
        margin-top: 5px;
        text-align: right;
    }
}

あまり関係ないですが、jsxからだとeCSStractorがそのまま使えないのが個人的にとても悲しいです。
HTMLから自動でCSSのセレクタを生成してくれるので、とても便利です。
しかもBEM記法にも対応しています。
jsxで使うときは、一旦classNameをclassに置換して生成して戻すみたいなことしています…。

https://packagecontrol.io/packages/eCSStractorpackagecontrol.io

まとめ

以上が、「ReactでLINE風チャット画面」の解説になります。
それぞれの分野でお詳しい方からすれば、ReactもCSSも大したことない内容ではあると思いますが、ReactとCSSの知識がそれぞれある程度必要という意味で面白そうでしたので記事にしてみました。

またコード量の問題で、一部抜粋する形での記載になっているので、解り辛い点もあるかと思います。
その際はお気軽にお問合わせいただければと思います。

最後に、シンクロ・フードではエンジニアを募集しています。
少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

www.synchro-food.co.jp