📫

Framerに興味があったので色々してみた[Sites/CodeOverrides]

2022/07/12に公開

framer-motionの方が有名かも知れませんが、今回はframerについてです。
framerにSitesというデザインした画面をそのまま公開出来る機能が5/25に公開され、それ移行気になっていました。さらにHandshake(β)というデザインしたコンポーネントをReactコンポーネントとして書き出せる事も知り、注目していました。
この記事では、SitesとFramerアプリ上でのコーディングについて検証したので、書いていきたいと思います。

Framer

簡単に言ってしまうと、Figmaの様なデザインツールです。以前はFramerXと呼ばれていたようです。私が触り始めたのは今回からになります。またFigmaも使い込んでいるわけでは無いので、差異については言及しません。ただ、両方とも少し触っただけですが、デザインする機能だけで言えば、周辺情報の量を含めFigmaを使ったほうが良いと思います。

https://www.framer.com/

先に目標と結果を

長くなりそうなので、先に目指したとこと結果を箇条書きします。

目標

Sitesのページを見れば、LPの様なデザインのみのページなら作れるのは一目瞭然なのと、Handshakeにも興味が有ったので、機能するページは可能か?を目標としました。

  • Formの様な入力を管理出来るか
  • ページのPropsについて確認
  • APIをfetchでGET出来るのか
  • POST可能か
  • ライブラリの追加は可能か
  • Sitesで公開しても機能するのか

結果

記事が少ないので調べるのに手間取りましたが、結果としては目標は全て達成しました。開発体験としても面白いと思いました。ただ、実用的かと言われる。。。最後にまとめて書きたいと思います。

  • デザインコンポーネントとCode Overridesでやりたい機能は実現出来た
  • Sitesで公開しました。

※実際に送信するので、お試しの際には注意してください。
Framer Sitesで公開:リンクはこちら

やってみる

まずは、デザインする

Framerはデザインツールなので、デザインしていきます。この辺はFigmaをイメージしていただければと思います。基本的にはビジュアルコーディングです。DOMとCSSをGUIで組んでいきます。

Code Overrides用のコードを書いていく


Framerのコード画面(バージョン2022.22.1 (2022.22.1))

Framerの左にあるCodeの横にある「+」をクリックすると、タイトル(ファイル名)とコードコンポーネントコードオーバーライドを選択するモーダルが表示されます。
今回はコードオーバーライドで作成します。
コードはReactのコードを書く事になります。なので、Reactを書いたことがあれば比較的取っ付きやすいと思います。私はReactについてはまだ1年も使っていません。それ以前はVueを愛用していました。
今回は紹介しませんが、コードコンポーネントでは、Reactそのままにコンポーネントを書いていきます。

Code Overridesのアタッチ

オーバーライドの指定は右のパネルから行います。ファイルと関数を選択します。
関数でコンポーネントを受け取り、必要な上書き処理を追加してレンダリングするのが基本の動作です。


右のパネル

export function OverrideComponent(Component): ComponentType {
    return (props) => {
        return <Component {...props}  />
    }
}

サンプル:コンポーネントとPropsを受け取って、そのまま返すなにも起こらないコード

ビルトインのInputコンポーネントから入力値を受け取る

Reactベースの処理が可能です。オーバーライドする関数が受け取るComponentonChange/valueを追加します。
一点違いがあるのは、ビルトインの場合onChangeから直接値を受け取れるのがポイントです。多分ビルトインなので、適宜処理をしてくれているのだと思います。

···
  const [valueState, setValueState] = useState('')
  const changeHandler = (val) => {
    setValueState(val)
  }
  return <Component {...props} onChange={changeHandler} value={valueState} />
···

ビルトインを独自コンポーネントでラップした場合。

画像のように、ビルトインのInputコンポーネントを使ったコンポーネントを作りました。便宜上InputTextとします。InputTextはビルトインのChangeイベント伝播させます。伝播させる方法は、InputTextコンポーネントを開いた状態で右のパネルから「interaction」を追加します。名前はChangeとします。
この際は、通常のinputタグと同じくイベントを受け取るので、適宜valueを取得します。

···
  const changeHandler = (event) => {
    setValueState(event.target.value)
  }
···

これで、ユーザーが入力した値をコード側で取得できるようになりました。

ストアとfetchを使って外部のAPIを叩いて、レスポンスを反映する

次にビジュアルコーディングだけでは出来ない事として、任意の外部APIをfetchを使って叩いてみたいと思います。Framerにもいくつかmailchimpなどのビルトインコンポーネントがあります。ただ、数も少ないし、実務ではレスポンスを受け取りたい場面も多いと思います。
そこで、今回「郵便番号データ配信サービス」のzipcloudを使って試してみました。
zipcloud API

ユーザーの入力値をコードで共有する
まず、Inputでユーザーの入力した値を、fetchする時に使いたいと思います。
FramerにはcreateStoreというグローバルなストアのようなものが用意されています。
Sharing Data(公式サイト)

// ライブラリの読み込み
import { createStore } from "https://framer.com/m/framer/store.js@^1.0.0"
// ストアの作成
const useStore = createStore({
  code: '',
  hoge: false
})
// ステートを更新する方
export function inputOverride(Component): ComponentType {
  return (props) => {
    const [store, setStore] = useStore()
    ···
    const changeHandler = (event) => {
      setStore({
        ...store,
        code: event.target.value
      })
    }
    ···
  }
}
// 使う方
export searchOverride(Component): ComponentType {
  return (props) => {
    const [store] = useStore()
    ···
    console.log(store.code)
    ···
  }
}

createStoreでHookを作っています。あとは作成したHookで値の参照と更新を行います。
ちなみに、このストアはFramer Sitesではページを遷移しても値は保持されているので、クリアする必要が出てくるかと思います。
ページ・ルート単位のストアがあるともっと便利そうですね。

fetchする
fetchに関しては、そのまま書くことが出来ました。
違和感が生じるとしたら、fetchのコードをボタンコンポーネントをオーバーライドする関数に書いている点でしょうか。
Reactなどであれば、ボタンコンポーネントのイベントを拾って、親コンポーネントがロジックを実行する、あるいはロジック専用のフックを実行すると思いますが、ビジュアルベースのFramerでは、コードを分けて書く事にあまりメリットは無さそうです。どちらかと言えば、1つのコンポーネントで完結させる方が良い気がしました。

export function submitButton(Component): ComponentType {
  return (props) => {
    const [store] = useStore()
    //
    const submitHandler = async () => {
      const res = await fetch(`API_URL${store.code}`, option)
      ···
    }
    //
    return <Component props={...props} onTap={submitHandler}>
  }
}

今回作ったサンプルプロジェクトでは、zipcloudから取得した住所をストアにセットしています。リアクティブに動作するので、ストアを更新すると表示(入力フィイールド)も更新されます。

ライブラリを使ってみる

入力と郵便番号検索が出来たので、送信前のバリデーションを追加してみたいと思います。また、外部のライブラリが使用可能か検証も兼ねています。
公式のドキュメントはこちら:Importing External Code

色々書いてありますが、npmモジュールを使えればたいていなんとかなりそうなので、ドキュメント通りjspmを使いました。
https://www.npmjs.com/package/is-emailのような場合、https://jspm.dev/is-emailで読み込むことが出来ます。

// メールアドレス用のバリデーター
import isEmail from "https://jspm.dev/is-email"

バリデーターとして superstructを使う

jspmを使ってsuperstructを読み込んで使用しました。
コードは長くなるので、要点のみ。

  • storeの値をsuperstructでチェックしています。
  • メールアドレスチェック用にis-emailを使っています。
  • エラーが発生したら、エラー用のストアを更新しています。
  • チェックはそれぞれのコンポーネント内に記述しています。

Superstructはこちら

入力内容を送信して、サンクスページへリダイレクト

バリデーションを通った値を送信する。今回は検証なので、送信先はGASにしました。
ここは郵便番号検索と同じくfetchを使ってポストするだけです。
レスポンスを受け取り、追加に成功したらサンクスページにリダイレクトします。ここが少し右往左往しました。
検索したのですが、それらしい記事は出てこなかったので、正式なものでは無いかも知れません。(どのみち使いづらいので公式で用意されるのを期待しています)

Thanksページにコード上から遷移する

下記に書きました。少し特殊です。
#ページ遷移

その他Tips

今回の検証と試作で気になったTipsを書き出してみました。

デバッグ

FramerAPPが多分Electronなので、ヘッダメニューからchromeのコンソールを表示することが出来ます。
右上のプレビューから再生すると、IDEでいうlocal開発と似たような形でコンソールを確認することが出来ます。今回もAPIの動作や、知らないメソッドの中身の確認などに使いました。

ページ遷移

Framerの書くPageを行き来するには、ビジュアルコーディングの方では右のパネルからLinkを追加するだけです。コードでページ遷移するには、FrameruseRouterを使います。このrouterにはFramerのルートにまつわる諸々が入っていました。router.navigate(id)各ルートのidをnavigateに渡すとページ遷移します。
navigateに渡すのがパスではなく、idなので、useEffectで欲しいパスのIDを取得しています。

export function backHomeBtn(Component): ComponentType {
    return (props) => {
        const router = useRouter()
        const [routerId, setRouterId] = useState("")
        // パスのIDを取得
        useEffect(() => {
            Object.keys(router.routes).filter((key) => {
                if (router.routes[key].path === "/") {
                    setRouterId(key)
                }
            })
        }, [router])
        // クリック
        const tapHandler = () => {
          // ページ遷移
          router.navigate(routerId)
        }
        return <Component {...props} onTap={tapHandler} />
    }
}

コンポーネントに任意の値を渡す

これをやりたかったのですが、諦めました。今回使った方法の一つとしては、propsに含まれるnameがレイヤー上の名前になるので、そちらを使ってストアの識別子としました。
CodeOverridesの関数選ぶ辺りに、任意の値を追加出来るともう少しスマートになるなとか思いました。

実際のコード

FormCode.tsx
import { define, refine, object, nonempty, string, validate } from "https://jspm.dev/superstruct"
//
import isEmail from "https://jspm.dev/is-email"
//
import type { ComponentType } from "react"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "framer"
import { createStore } from "https://framer.com/m/framer/store.js@^1.0.0"

// バリデーション用
const Email = define("Email", isEmail)
const PostStruct = object({
    code: nonempty(string()),
    address1: nonempty(string()),
    address2: nonempty(string()),
    address3: nonempty(string()),
    email: Email,
})

// 入力用ストア
const useStore = createStore({
    code: "",
    address1: "",
    address2: "",
    address3: "",
    email: "",
})

// ローディング表示用フラグ
const useLoading = createStore({
    isLoading: false,
})

// エラーストア
const useError = createStore({
    code: "",
    address1: "",
    address2: "",
    address3: "",
    email: "",
})

// 郵便番号の検索
export function searchPostCode(Component): ComponentType {
    return (props) => {
        const [store, setStore] = useStore()
        const [error, setError] = useError()
        const [loading, setLoading] = useLoading()
        //
        const onSearch = () => {
            async function callSearch(code) {
                setLoading({ isLoading: true })
                const res = await fetch(
                    `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${code}`
                ).then((r) => r.json())
                if (res.status === 200) {
                    if (res.results.length > 0) {
                        setStore({
                            ...store,
                            address1: res.results[0].address1,
                            address2: res.results[0].address2,
                            address3: res.results[0].address3,
                        })
                    }
                }
                //
                setLoading({ isLoading: false })
            }
            //
            if (store.code === "") {
                setError({
                    ...error,
                    code: "入力してください",
                })
            } else {
                callSearch(store.code)
            }
        }
        //
        return <Component {...props} onTap={onSearch} />
    }
}

// 自作コンポーネントのInputTextの更新
export function inputTextCode(Component): ComponentType {
    return (props) => {
        const [store, setStore] = useStore()
        const [error, setError] = useError()
        //
        const changeHandler = (event) => {
            let addVal = {}
            addVal[props.name] = event.target.value
            setStore({
                ...store,
                ...addVal,
            })
            setError({
                code: "",
                address1: "",
                address2: "",
                address3: "",
                email: "",
            })
        }
        return (
            <Component
                {...props}
                value={store[props.name]}
                onChange={changeHandler}
                //エラー
                error={error[props.name]}
                visible={error[props.name] !== ""}
            />
        )
    }
}

//
const API_URL = "GASのURL"

// 送信
export function submit(Component): ComponentType {
    return (props) => {
        const [store, setStore] = useStore()
        const [error, setError] = useError()
        const [loading, setLoading] = useLoading()

        //
        const router = useRouter()
        const [thanksId, setThanksId] = useState("")

        //
        const onSubmit = async () => {
            setLoading({ isLoading: true })

            //
            const [validateError, validateSuccess] = validate(
                {
                    code: store.code,
                    address1: store.address1,
                    address2: store.address2,
                    address3: store.address3,
                    email: store.email,
                },
                PostStruct
            )

            //
            if (validateError) {
                const addError = {}
                switch (validateError.refinement) {
                    case "nonempty":
                        addError[validateError.key] = "入力されていません"
                        break
                    default:
                        if (validateError.key === "email") {
                            addError[validateError.key] =
                                "メールアドレスが正しく有りません"
                        } else {
                            addError[validateError.key] =
                                "正しく入力されていません"
                        }
                }

                //
                setError({
                    ...error,
                    ...addError,
                })

                //
                setLoading({ isLoading: false })

                //
                return false
            }

            //
            const params = new URLSearchParams()
            params.append("code", store.code)
            params.append("address1", store.address1)
            params.append("address2", store.address2)
            params.append("address3", store.address3)
            params.append("email", store.email)

            //
            const res = await fetch(API_URL, {
                method: "POST",
                headers: {
                    Accept: "application/json",
                    "Content-Type": "application/x-www-form-urlencoded",
                },
                body: params,
            }).then((r) => r.json())

            //
            router.navigate(thanksId)

            // ページ遷移するがストアは保持されるようなので、クリアする
            setLoading({ isLoading: false })
            setStore({
                code: "",
                address1: "",
                address2: "",
                address3: "",
                email: "",
            })
            setError({
                code: "",
                address1: "",
                address2: "",
                address3: "",
                email: "",
            })
        }

        //
        useEffect(() => {
            if (router) {
                Object.keys(router.routes).filter((key) => {
                    if (router.routes[key].path === "/thanks") {
                        setThanksId(key)
                    }
                })
            }
        }, [])

        //
        return <Component {...props} onTap={onSubmit} />
    }
}

// ローディング
export function loadingComponent(Component): ComponentType {
    return (props) => {
        const [loading, setLoading] = useLoading()
        return <Component {...props} visible={loading.isLoading} />
    }
}

// サンクスページのHomeに戻るボタン用
export function backHomeBtn(Component): ComponentType {
    return (props) => {
        const router = useRouter()
        const [routerId, setRouterId] = useState("")

        //
        useEffect(() => {
            Object.keys(router.routes).filter((key) => {
                if (router.routes[key].path === "/") {
                    setRouterId(key)
                }
            })
        }, [router])

        //
        const tapHandler = () => {
            router.navigate(routerId)
        }

        //
        return <Component {...props} onTap={tapHandler} />
    }
}

Framer Sitesで公開:リンクはこちら

やってみの感想

Framerのコードは思った以上にReactそのままだった印象です。資料は少ないですが、Reactでのやり方を調べると(今回でいうとバリデーション)そのまま動きました。
使い勝手としては、エディタもある程度補完が効くので、補助的な機能のコードを書く位であれば問題ないと思います。ただ、コードとビジュアルを行ったり来たりするので、シンプルにコードを含めて俯瞰出来るとは言いにくいと思います。やはりコードは補助的な使い方でしょうか。
全体としてビジュアルエディタの方もまだ使い勝手よくない部分もあるので、コードのここがというより全体として誰にでもオススメとはならないかと言う感じです。

Sites
コードとは離れるかも知れませんが、デザインツールでデザインしてそのままWEB公開出来るので、Sitesは使い所が多くありそうだと思いました。その際に少しコードで補完したい時などに、CodeOverridesの機能が使えるので、このまま進化すると面白そうだなと思いました。


Handshake(β)について

Framerでデザインしたコンポーネントを、そのままReactコンポーネントとして利用できる機能です。
こちらも試してみているので、また記事に出来たらと思っています。

Discussion