マイクロインタラクションで仕事探しを楽しくする

この記事は スタンバイ Advent Calendar2023 の19日目の記事です。

https://qiita.com/advent-calendar/2023/stanby

# はじめに
こんにちは。株式会社スタンバイでデザイナーをしている齋藤です。
求人検索サービス「スタンバイ」のネイティブアプリを中心に、幅広くUI/UXを考える仕事をしています。


この記事では、他の施策に追われて後回しになりがちな「UI操作時のインタラクション」について、デザインから実装までのコミュニケーションで困りそうな点を想定しつつ、実現までの道のりを書いていきます。

（おもしろポイントは、前半の[イージング基礎知識](#イージングまとめ)と、後半で[SwiftUIのspringアニメーションをOpenCVでkeyframeに変換する異常ノウハウ](#複雑なspringアニメーションをキーフレームに変換する)が出てくるところです）

# 仕事探しは面倒で大変
突然ですが、仕事を探している時って（自分も含めて）楽しくない場面が多いですよね。
- 希望の仕事が見つからない:cry:
- そもそも探すことが面倒:angry:
- 応募しても採用されるか不安:fearful:

私たちも、検索精度、求人品質、UXなど様々な改善に取り組んでるものの、基本的な体験としてはポジティブな気持ちになりにくい。

そんな仕事探しが少しでも前向きなものになるよう、操作自体を楽しくできないか？というところから、マイクロインタクションをきちんとデザインしたいと以前から考えていました。

# 今回やること
例えば、お気に入りボタンを押した時のアニメーション。

<img width="375px" alt="スタンバイのiOSアプリでお気に入りボタンをタップした際のアニメーション" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622997/75f276ad-93ce-ca65-da68-2c8987cc2179.gif">

これをもっと "良く" していきます。

# 期待する動きを実装できる仕様に落とす
シンプルなアニメーションなら `duration: 0.3s、easing: easeOutで背景色を変更`のような指定をすればOKですが、「ボタンを押した時、なんかピョコンと楽しい感じの動きにしたい！」くらいの理解度の場合、そもそも今の仕組みで実装できるのか、できる場合はどのような指定が必要なのかを明確な仕様に落とす必要があります。

## エンジニアに相談
実装のことは分からないけどイメージはある！という場合は、近い動きのアプリなどを見せつつ希望を伝えて、まるっと調査から依頼するのも一つの方法かなと思います。
この場合、
- エンジニアがアニメーション実装に詳しい場合は、一発で期待する動きになる

一方で、
- イメージと違った場合、どのように修正依頼したら良いかデザイナーも分からない

という可能性があります。

大抵は、何往復か動きの調整を経て完成にすると思うのですが、「定量的な効果の小さい案件に工数をかけられない」とか「依頼時のコミュニケーションがスムーズにいかない予感がする」とか、様々な理由で「やっぱ辞め！」となることもあるかもしれません。

いや、でもやりたいんだよ！オイ！

……ということで、まあ、勉強するか〜。

基本的な仕組みを理解することで、無理なく実装できるアニメーションを自力で考え、依頼できる状態を目指しましょう。

## 仕組みを理解する
昨今、検索すれば大抵の情報は出てきます。

https://easings.net/ja#

アニメーションの基礎として、イージングはざっくり理解しておくと便利です。

### イージングまとめ
- イージング＝値をAからBまでどんな速度で変えるかを数式で表現したもの
- イージングの種類を変えると色々な動きを実現できる
- 単純な形状のイージングはCSSのtransitionで指定できる
- 複雑な形状のイージングもCSSのkeyframeで近似的に指定できる
- 自然に見えやすいイージングの使い分け
  - easeOut: ボタンの色などユーザの操作を受けたインタクション
  - easeIn: 止まっているものが動き出し画面外に出ていく
  - easeInOut: 止まっているものが動き出し別の座標に移動する
  - ease: 最初に弱いeaseIn、後半強めにeaseOutのような動き、CSSにある
- アニメーションの時間（duration）が長いとユーザの操作を阻害するので、短めにすると程よい感じになる（0.2〜0.5sくらい）
- 迷ったときは現実空間の物体の振る舞いを参考にすると自然な動きになる
- UI操作に過剰なインタラクションは不要

この辺りの考え方は、iOSやAndroidのネイティブ実装でも共通しているので、UIアニメーションは基本的にこの範囲でイージングと時間を指定すれば必要十分でしょう。

## でもまだ思ってたのと違う
「イージングはわかったけど、そのピョコンじゃないんだよな〜」という時は何らか実装的な頑張りが必要で、大抵はイージングで対応した方が絶対に合理的です。

が、場合によってフレームワークがいい感じのアニメーションを関数として持っていることもあります。
例えば、`SwiftUI アニメーション ばね`で検索してみると、iOSにはSpring animationという関数があることが分かります。

https://github.com/GetStream/swiftui-spring-animations

WWDC23での解説セッションが動画で公開されていました。

https://developer.apple.com/videos/play/wwdc2023/10158/

## 動かしてみる
ChatGPTにコードを書いてもらって

![SwiftUIのPlaygroundで320角の画面に100pxの黒い正方形を配置します。白背景の画面をタップするとspringアニメーションで正方形のScaleを1と1.2でトグルします。springには適度な値で引数を指定してください。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622997/edbda11a-ceb2-ae44-4d31-a25c3c64ae7c.png)

```swift

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    @State private var isScaled = false

    var body: some View {
        ZStack {
            Color.white
            Rectangle()
                .frame(width: 24, height: 24)
                .scaleEffect(isScaled ? 1.2 : 1)
                .animation(.spring(response: 0.4, dampingFraction: 0.4, blendDuration: 0.2), value: isScaled)
        }
        .frame(width: 320, height: 320)
        .background(Color.white)
        .onTapGesture {
            isScaled.toggle()
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())
```

Swift Playgroundsに貼って実行します。

<img width="300px" alt="ChatGPTが書いたアニメーション" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622997/4dbcc096-3111-e997-f457-1f282ab8b813.gif">

いいピョコンの気配があります。
色々と数値を変更しながら関数を実行すると、パラメータと動きの関係が分かるデザイナーの誕生です。

だだし、こうして生成したコードがそのまま使えることはほぼ無いので、一個前のステップで実装方法をエンジニアと相談しましょう。

ここから諸々がんばって、期待する動きを実現します。

できました。

# 複雑なspringアニメーションをキーフレームに変換する
iOSがいい感じになったところで、他の環境でも同じ動きをつけたいですよね。が、そんな関数はありません。

残念。

さて、ここでイージングの説明を思い出してください。
- 複雑な形状のイージングもCSSのkeyframeで近似的に指定できる

やりましょう。


## 人力で頑張る場合
方針としては、scaleの増減が切り替わるカーブの山と谷をキーフレームと考えれば良さそうです。

1. 変換したいアニメーションを動画でキャプチャする
    - 動かす図形は大きければ大きいほどアニメーションの誤差が減ります
1. 動画を1フレームずつ進めて、動きが止まる山や谷の経過時間をメモしつつ静止画としてFigmaなどにコピペ
1. コピペしたフレームの四角をトレースし、px数からscaleを計算する
1. サイズが最後に変わった時間と最初に変わった時間の差をアニメーション全体のdurationとする

力技ですが、これが一番簡単です。

最初のキーフレームはanimation-timing-functionをease、それ以外はease-in-outにしておくと良さそう。

## pythonで自動化
ただ、人力でやるの面倒なんですよね。
仕組みが分かる単純作業は人間の仕事じゃないので、機械にやってもらいましょう。

```python:extract_keyframes.py
import cv2

def extract_keyframes(video_path):
    """
    Scaleアニメーションする正方形の動画を分析し、キーフレームを抽出する

    :video_path: 動画のパス
    :return: 時間、スケール、方向を含むタプルのリスト
    """

    cap = cv2.VideoCapture(video_path)

    # フレームレートの取得
    fps = cap.get(cv2.CAP_PROP_FPS)

    # 初期設定
    results = []
    frame_count = 0  # フレームカウンタ
    initial_width = None  # 最初の幅
    previous_width = None  # 前回の幅
    previous_direction = None  # 前回の方向
    first_scale_change_frame = None  # 最初にサイズが変わったフレーム
    last_scale_change_frame = None  # 最後にサイズが変わったフレーム
    last_direction = None  # 最後にサイズが変わった方向

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break

        # グレースケールに変換
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        # 二値化
        _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

        # 輪郭を検出
        contours, _ = cv2.findContours(
            binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
        )

        if contours:
            # 最大の輪郭を取得
            max_contour = max(contours, key=cv2.contourArea)
            x, y, w, h = cv2.boundingRect(max_contour)

            # 方向を決定（増加、減少、変化なし）
            if previous_width is None:
                direction = "same"
                initial_width = w
            elif w > previous_width:
                direction = "increase"
            elif w < previous_width:
                direction = "decrease"
            else:
                direction = "same"

            if previous_direction is not None and previous_direction != direction:
                if first_scale_change_frame is None:
                    first_scale_change_frame = frame_count - 1

                else:
                    scale = float(previous_width) / float(initial_width)
                    time_from_start = (frame_count - 1 - first_scale_change_frame) / fps
                    results.append((time_from_start, scale, direction))
                last_scale_change_frame = frame_count
                last_direction = previous_direction

            previous_direction = direction
            previous_width = w
        frame_count += 1

    cap.release()

    # 最後のスケール変更を追加
    if last_scale_change_frame is not None:
        final_scale = float(previous_width) / float(initial_width)
        final_time_from_start = (
            last_scale_change_frame - first_scale_change_frame
        ) / fps
        results.append((final_time_from_start, final_scale, last_direction))

    return results

def merge_keyframes(results):
    """
    'same'のキーフレームを後のフレームに統合し、連続する同方向のキーフレームを後のフレームに統合する。

    :param results: 時間、スケール、方向を含むタプルのリスト
    :return: 統合されたキーフレームを含む更新されたリスト
    """
    # 'same'のキーフレームを統合
    merged_results = []
    i = 0
    while i < len(results):
        if i < len(results) - 1 and results[i][2] == "same":
            merged_time = (results[i][0] + results[i + 1][0]) / 2
            merged_scale = results[i + 1][1]
            merged_direction = results[i + 1][2]
            merged_results.append((merged_time, merged_scale, merged_direction))
            i += 2  # 次のキーフレームもスキップ
        else:
            merged_results.append(results[i])
            i += 1

    # 'increase'と'decrease'のキーフレームを統合
    final_results = []
    i = 0
    while i < len(merged_results) - 1:
        current_direction = merged_results[i][2]
        next_direction = merged_results[i + 1][2]

        if current_direction == next_direction:
            # 同じ方向が連続する場合はスキップ
            i += 1
        else:
            # 方向が切り替わる時には後の要素を残す
            final_results.append(merged_results[i + 1])
            i += 2  # 次の要素へ進む

    # リストの最後の要素を確認して追加（方向が変わらない場合）
    if i == len(merged_results) - 1:
        final_results.append(merged_results[-1])

    return final_results

def generate_css_keyframes(results):
    """
    与えられたデータからCSSのキーフレームを生成する

    :param results: 時間、スケール、方向を含むタプルのリスト
    :return: 生成されたCSSキーフレームと .animate クラスの定義を含む文字列
    """

    duration_ms = int(results[-1][0] * 1000)
    animation_text = "@keyframes scaleAnimation {\n"

    for index, (time, scale, _) in enumerate(results):
        percentage = int(time / results[-1][0] * 100)

        # 最初のキーフレームにはease、それ以外にはease-in-outを適用
        easing = "ease" if index == 0 else "ease-in-out"

        animation_text += f"    {percentage}% {{ transform: scale({scale:.2f}); animation-timing-function: {easing};}}\n"

    animation_text += "}\n"
    animation_text += (
        f".animate {{\n    animation: scaleAnimation {duration_ms}ms forwards;\n}}"
    )

    return animation_text

video_path = "input.mov"
results = extract_keyframes(video_path)
merged_results = merge_keyframes(results)
css_animation = generate_css_keyframes(merged_results)

print(css_animation)
```
input.movは、先ほどの黒い正方形の動きをキャプチャしたものから、必要な所をQuickTimeで切り出して使います。

```
% python extract_keyframes.py

@keyframes scaleAnimation {
    41% { transform: scale(1.25); animation-timing-function: ease;}
    83% { transform: scale(1.19); animation-timing-function: ease-in-out;}
    100% { transform: scale(1.20); animation-timing-function: ease-in-out;}
}
.animate {
    animation: scaleAnimation 658ms forwards;
}
```

良さそうな気がする。

<img width="300px" alt="CSSのアニメーション" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622997/2cc2175e-f644-619b-27b6-f33bfde144d4.gif">

厳密には、ばねの反発は徐々に減衰するので、easeIn強めeaseOut弱めのカーブをcubic-bezierで書くとさらに良い可能性がある。



# お気に入りボタンはどうなったの？

そういえば、スタンバイアプリのお気に入りボタンを押した時のアニメーション。
こちら、動きを改良したものが2024年1月くらいにリリースされている予定です。

よろしければインストールして使ってみていただけると嬉しいです。

https://apps.apple.com/jp/app/%E3%82%B9%E3%82%BF%E3%83%B3%E3%83%90%E3%82%A4-%E6%B1%82%E4%BA%BA%E3%82%92%E6%A4%9C%E7%B4%A2%E3%81%97%E3%81%A6%E3%83%90%E3%82%A4%E3%83%88-%E8%BB%A2%E8%81%B7-%E4%BB%95%E4%BA%8B-%E6%8E%A2%E3%81%97/id1516762850

https://play.google.com/store/apps/details?id=com.stanby.app&hl=ja&gl=US&pli=1