こんにちは、freee 会計のワークフロー(会社内の申請・承認の流れ)機能を開発しているエンジニアのミツバ (@mitubaEX) です。
先日ワークフローの機能の一つである支払依頼を刷新しました。この記事では刷新した手順とこだわりポイントについて紹介したいと思います。
以前の支払依頼について
支払依頼は主に請求書を元に申請を出し、稟議を回すものになっています。 刷新以前の UI は以下のようなものでした。
左側に取引先の情報、右上に請求書の明細の情報が表示されており、右下に請求書の画像が添付されています。この UI は明細が増えていくと請求書が画面下部に表示されてしまいます。これによって申請後に承認をする人が請求書を確認して入力された明細のデータを確認するための視線の移動が多くなってしまうという問題が生じます。 加えて昔から利用されている UI component で組み立てられた画面だったので、これを機に刷新することになりました。
新規の UI でどうなったか
さっそくですが、完成した UI をご覧ください。
刷新以前の UI で生じていた請求書が画面下部に表示されてしまう問題は、請求書の位置が画面の左に表示されるようになったことで解消されました。また明細行などが増えた場合でも、右下の領域には明細行しか無いため他の項目が隠れることも無くなりました。
そして今回新たに導入した UI として「請求書の表示領域のスクロールへの追従」と「請求書の オンマウスによる画像拡大 (以下、請求書プレビューと記します)」があります。これらを実装することにより、明細行が増えた場合にも請求書を確認でき、いつでもマウスを近づけることで請求書の内容を確認することができます。
では、この UI をどのようにして開発していったかを紹介していきたいと思います。
開発手順
設計フェーズ
まず、freee では機能を開発する時に、DesignDoc を作成して変更箇所を確認してから実装をします。そこで UI/UXチーム が作成したデザインを元に、設計方針を確定させます。ここで請求書プレビューの機能で参考にできる実装が外部ライブラリしかなかったため DesignDoc 作成時には、とりあえずプロトタイプレベルのものを作成し、実現可能かを判断する形で開発を始めました。
外部ライブラリーとしては、mark-rolich/Magnifier.js がありましたが、今回は小さめな実装で拡張性を重視したため、独自に実装する形を取りました。
UI/UX デザイナーの yuri さんが UI 作成時の話を記事にしているので合わせて読んでみてください。
実装フェーズ
開発は自分含めて二人で行いました。実装期間は一ヶ月弱です。
freee では Vibes というデザインシステムを利用しており、基本はそれに沿って実装していきます。今回だと、取引先の情報、明細の情報などは component を置き換える形で実装しています。ここでは裏側の state の持ち方やロジックの部分までは手を入れず、 UI 刷新のみにフォーカスして実装していきました。
新規で実装した請求書プレビューの機能は独自に実装して難しかった点も多くあったので、その点はこの記事の後半で紹介しようと思います。
QA フェーズ
UI を刷新した後は、QA チームの方に QA テストをお願いしました。実装を簡略化してシンプルにした部分があるため、デグレチェックも兼ねて厚めにしてもらいました。そこで出た不具合は、平行して直していきました。また、UI/UX デザイナーの方にも見てもらい、そこで出た課題についても実装を行いました。
QA 期間は、2 週間強ほどの期間を取りました。
リリース
諸々の作業が終わったら、晴れてリリースです。リリース後は、顧客からの問い合わせがあるかどうかを逐一確認し、問い合わせが上がってきたら優先的に対応していきます。また改善系のフィードバックも確認し、少しずつ対応していきます。
請求書プレビューの実装の紹介
独自で実装した請求書プレビューはそこまで大きくないものなのですが、0 から実装してみて難しかったことも多くありました。今回は、その請求書プレビューの実装周りも紹介できればと思います。
最初の 請求書プレビュー
最初の 請求書プレビューの実装は以下のようになりました。画像をはみ出して、拡大された領域(以下、拡大鏡と記します)が表示されています。
この実装は以下の実装を元に実装しています。 https://www.w3schools.com/howto/howto_js_image_zoom.asp
実装の問題点
この実装の問題は拡大鏡が画像をはみ出して表示されてしまう点にあります。せっかく拡大しているのに、他に表示されている要素と重なってその要素が見えなくなってしまったら、拡大している意味が薄れてしまいます。つまり、拡大鏡の表示領域は表示されている画像の表示領域内に収める必要があります。
この問題を解消するため、請求書プレビューの改良を行いました。
改良後の 請求書プレビュー
最終的な実装は以下のようになりました。拡大鏡を画像の表示領域に収めるようにしました。
拡大鏡の座標計算について
最初の 請求書プレビューで問題になっていた部分をいかに解決したのかを解説していきます。まず改良後の 請求書プレビューの動作は以下のようになっています。
- マウスの座標を取り、その座標から拡大鏡を表示させる位置を確定し表示する。この時、拡大鏡が画像内に収まるように移動距離を制御します。
- 「(画像の幅 * 拡大率 - 拡大鏡の幅) / 画像の幅」 という計算をします。この値は、background-position の動ける割合を調整するために利用します。
- 最後に、今のマウス座標に 2. で求めた値を掛けて、background-position の値として利用します。
何故この実装にしたか説明するために、図を用意しました。図中の内側の長方形が表示されている画像だと思ってください。大きさは、480 px * 680 px とします。ここで拡大鏡によって元の画像が1.5 倍にされるとすると、 拡大された 720 px * 1,020 px の画像が元の画像の裏側に置かれる形になります。この拡大された画像の一部を拡大鏡は表示します。図では、拡大鏡は左上に存在しています。
ここで最初の実装を考えます。最初の実装が画像外に拡大鏡を表示してしまっていたのは、画像を拡大すると図のように拡大された画像が元の画像より大きくなるためでした。なので、拡大鏡が拡大された画像を端まで表示するためには、画像外に拡大鏡を表示するほかありませんでした。
では画像内にこの拡大鏡を収めるためにはどうすれば良いでしょうか?
拡大鏡の移動を画像内に収めるのは単純な座標の制御のみですが、拡大鏡が表示するべき画像の座標を計算するためには、このままの考え方では厳しいです。なので、拡大鏡が表示するべき画像の座標を表示されている画像の比率に合わせてあげるようにします。ここで、やっと 「(画像の幅 * 拡大率 - 拡大鏡の幅) / 画像の幅」 という計算式を利用します。下の図だと 横幅に関しては「(480 * 1.5 - 350) / 480 = 0.770833333」のような計算となります。この値は、見て分かるように拡大された外側の画像の幅から拡大鏡の幅を引いて、元画像での割合を出しています。
拡大鏡の幅分(ここでは 350 px)を引く理由は、拡大鏡で本来画像外に描画される部分が画像内に押し込まれることにより不要になるからです。ここでの「拡大鏡で本来画像外に描画される部分」というのは、左右ではみ出る拡大鏡の半分の領域のことで、合わせると拡大鏡一個分となります。拡大鏡の真ん中にマウスが来るように調整しているため、その分を考慮しています。この不要な拡大鏡の幅分を省いて、画像内で表示できる領域の幅から割合を出します。
最後にこの値を、現在のマウス座標に掛けると画像外にはみ出さず、適切に拡大鏡による拡大が行えるようになります。
イメージが掴みづらいかもしれないので、ソースコード (Reactのロジック) も貼っておきます。興味のある方は読んでみてください。なお拡大鏡のことをコード上では、 Lens と呼んでいます。
// (画像の幅 * 拡大率 - 拡大鏡の幅) / 画像の幅 const calcScaledRate = (imageSize, lensSize, scale) => { const scaledSize = (imageSize * scale - lensSize) / imageSize; return scaledSize < 0 ? 0 : scaledSize; }; // lens を動かし拡大された画像の position を移動する useLayoutEffect(() => { // ref を取得する const currentImageRef = imageRef.current; const currentLensRef = lensRef.current; // lens の x, y 座標を確定する let x = positionX - currentLensRef.offsetWidth / 2; let y = positionY - currentLensRef.offsetHeight / 2; // preview 領域から出ないように調整する if (x > currentImageRef.width - currentLensRef.offsetWidth) { x = currentImageRef.width - currentLensRef.offsetWidth; } if (x < 0) { x = 0; } if (y > currentImageRef.height - currentLensRef.offsetHeight) { y = currentImageRef.height - currentLensRef.offsetHeight; } if (y < 0) { y = 0; } setLensX(x); setLensY(y); // 画像の幅に scale を適応した幅に対して、Lens 分を引いたものが今回の動ける範囲になる // なので、その範囲と 元の画像の幅の比率を計算し、position をその分ずらすようにする const backgroundPositionXScaledRate = calcScaledRate( currentImageRef.width, currentLensRef.offsetWidth, scale ); const backgroundPositionYScaledRate = calcScaledRate( currentImageRef.height, currentLensRef.offsetHeight, scale ); setBackgroundPosition( `-${positionX * backgroundPositionXScaledRate}px -${positionY * backgroundPositionYScaledRate}px` ); }, [positionX, positionY, scale]);
最後に
支払依頼 UI 刷新のプロジェクトについて紹介しました。支払依頼は今後も機能拡張をしていく予定があります。今後の進化にご期待ください。