0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qwik(City)の世界: UI構築とトランジション #3

Last updated at Posted at 2024-12-19

はじめに

はじめまして、WEB フロントエンドエンジニアの nuintee です。

この度かねてより関心があった Qwik に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。

Qwik に少しでも興味のある方は、ぜひご覧ください。

フロントエンドの世界 2024 について

フロントエンドの世界 2024」は普段 Next.js を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit), Remix ,SolidJS, Qwik(City)の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。

frontend-assort-2024-banner.png

もくじ

ディレクトリ構成

tree実行結果
.
├── README.md
├── package.json
├── public
│   ├── favicon.svg
│   ├── manifest.json
│   └── robots.txt
├── qwik.env.d.ts
├── src
│   ├── components
│   │   └── router-head
│   │       └── router-head.tsx
│   ├── entry.dev.tsx
│   ├── entry.preview.tsx
│   ├── entry.ssr.tsx
│   ├── features
│   │   ├── form
│   │   │   └── types
│   │   │       └── index.ts
│   │   ├── game
│   │   │   ├── constants
│   │   │   │   └── index.ts
│   │   │   └── types
│   │   │       └── index.ts
│   │   ├── pokemon
│   │   │   ├── types
│   │   │   │   └── index.ts
│   │   │   └── ui
│   │   │       └── Card
│   │   │           └── index.tsx
│   │   └── time
│   │       └── types
│   │           └── index.ts
│   ├── global.css
│   ├── root.tsx
│   ├── routes
│   │   ├── index.tsx
│   │   ├── layout.tsx
│   │   └── service-worker.ts
│   ├── styles
│   │   └── index.module.css
│   ├── ui
│   │   ├── CharButton
│   │   │   ├── index.tsx
│   │   │   └── type.ts
│   │   ├── LifeCounter
│   │   │   └── index.tsx
│   │   ├── Loader
│   │   │   └── index.tsx
│   │   ├── PrimaryButton
│   │   │   ├── index.tsx
│   │   │   └── type.ts
│   │   └── ProgressBar
│   │       ├── index.tsx
│   │       └── type.ts
│   └── views
│       ├── GamePlayView
│       │   ├── index.module.css
│       │   ├── index.tsx
│       │   └── type.ts
│       └── GameResultView
│           ├── index.module.css
│           ├── index.tsx
│           └── type.ts
├── tsconfig.json
└── vite.config.ts

機能の型定義

PokeAPI のレスポンス

features 配下にポケモン API の取得レスポンス型を定義します。

(参考: PokeAPI)

features/pokemon/types/index.ts
export type SpecieResponse = {
  names: {
    language: {
      name: string;
    };
    name: string;
  }[];
};

export type PokemonResponse = {
  id: number;
  name: string;
  sprites: {
    other: {
      home: {
        front_default: string;
      };
    };
  };
};

export type PokemonType = SpecieResponse & PokemonResponse;

回答入力フォーム

回答欄と選択肢で共通して使う型を定義します。

features/form/types/index.ts
export type FormType = {
  char: string;
  index: number;
};

export type FormValues = {
  options: FormType[];
  displayTexts: FormType[];
};

モックページの実装

まずは固定のデータで UI を実装します。

リザルト画面

Qwik のcomponent$を使用してリザルト画面を実装します。

イベントハンドラーは$サフィックスを付けた命名にし、関数の型は QRLにジェネリクスとして渡すことで Qwik 用のイベントハンドラー型を定義できます。

(参考: component$ / Custom Event Props)

views/GameResultView/type.ts
// NOTE: QRLはQwik特有の型で、関数型のpropsに使用します
import { QRL } from "@builder.io/qwik";

export type GameResultViewProps = {
  onRetry$: QRL<() => void>;
};

スタイリングは CSS Modules を使用します。

(参考: CSS Modules)

views/GameResultView/index.module.css
.section__container {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
}

.score__container {
  display: flex;
  padding: 1rem;
  flex-direction: column;
  row-gap: 1rem;
  border: 1px solid gainsboro;
  border-radius: 10px;
}

.score__label {
  font-weight: 400;
  text-align: center;
  color: rgb(100 116 139);
}

.score__value {
  font-size: 1.5rem;
  line-height: 2rem;
  text-align: center;
}

.score__unit {
  font-size: 0.875rem;
  line-height: 1.25rem;
}

.primary__button {
  padding-top: 1rem;
  padding-bottom: 1rem;
  padding-left: 2rem;
  padding-right: 2rem;
  max-width: 100%;
  font-weight: 500;
  background: greenyellow;
  width: 350px;
  border: none;
  cursor: pointer;
  border-radius: 10px;

  &:hover {
    opacity: 0.75;
  }

  &:active {
    opacity: 0.5;
  }
}
views/GameResultView/index.tsx
import type { GameResultViewProps } from "./type";
import { component$ } from "@builder.io/qwik";

import styles from "./index.module.css";

// NOTE: component$でコンポーネントを定義します
export const GameResultView = component$(
  ({ onRetry$ }: GameResultViewProps) => {
    const { score } = {
      score: 3,
    };

    return (
      <section class={styles.section__container}>
        <div class={styles.score__container}>
          <h2 class={styles.score__label}>スコア</h2>
          <p class={styles.score__value}>
            {score}
            <span class={styles.score__unit}></span>
          </p>
          {/* NOTE: onClick$のように$サフィックスを付けてイベントハンドラを定義 */}
          <button onClick$={onRetry$} class={styles.primary__button}>
            もう一度プレイする
          </button>
        </div>
      </section>
    );
  }
);

プレイ画面

同様にプレイ画面も実装します。

views/GamePlayView/type.ts
import { QRL } from "@builder.io/qwik";
import type { FormType } from "~/features/form/types";
import type { PokemonType } from "~/features/pokemon/types";

export type GamePlayViewProps = {
  displayTexts: FormType[];
  options: FormType[];
  pokemon: PokemonType;
  // NOTE: すべてのイベントハンドラpropsに$サフィックスを付ける
  onAddInput$: QRL<(input: FormType) => void>;
  onRemoveInput$: QRL<(input: FormType) => void>;
  onAnswer$: QRL<() => void>;
};
views/GamePlayView/index.module.css
.section__container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.progressBar__container {
  width: 100%;
  height: 0.75rem;
  background: #cbff7e;
}

.progressBar__track {
  border-top-right-radius: 0.375rem;
  border-bottom-right-radius: 0.375rem;
  height: 100%;
  transition-timing-function: linear;
  background: yellowgreen;
}

.lifeCounter__container {
  display: flex;
  padding-left: 1rem;
  padding-right: 1rem;
  margin: 1rem;
  column-gap: 0.5rem;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
}

.lifeCounter__item {
  font-size: 20px;
}

.title__text {
  display: flex;
  margin-top: 1rem;
  justify-content: center;
  align-items: center;
  font-size: 1.5rem;
  line-height: 2rem;
  font-weight: 500;
}

.displayText__container {
  display: flex;
  column-gap: 1rem;
  align-items: center;
  height: 70px;
}

.options__container {
  display: flex;
  margin: 0.5rem;
  column-gap: 1rem;
  align-items: center;
  height: 70px;
}

.fallback__text {
  display: flex;
  justify-content: center;
  align-items: center;
  color: #9ca3af;
}

.card__container {
  max-width: 100%;
  height: fit-content;
  width: 350px;
}

.primary__button {
  padding-top: 1rem;
  padding-bottom: 1rem;
  padding-left: 2rem;
  padding-right: 2rem;
  max-width: 100%;
  font-weight: 500;
  background: greenyellow;
  width: 350px;
  border: none;
  cursor: pointer;
  border-radius: 10px;

  &:hover {
    opacity: 0.75;
  }
}

.char__button {
  padding-top: 1rem;
  padding-bottom: 1rem;
  padding-left: 1.5rem;
  padding-right: 1.5rem;
  background: ghostwhite;
  border: none;
  cursor: pointer;
  border-radius: 10px;

  &:hover {
    opacity: 0.75;
  }

  &:active {
    opacity: 0.5;
  }
}

.separator {
  width: 100%;
  height: 1px;
  background-color: #e5e7eb;
}
views/GamePlayView/index.tsx
import { component$ } from "@builder.io/qwik";
import type { GamePlayViewProps } from "./type";

import styles from "./index.module.css";

export const GamePlayView = component$(
  ({
    pokemon,
    options,
    displayTexts,
    onAddInput$,
    onRemoveInput$,
    onAnswer$,
  }: GamePlayViewProps) => {
    const lifeCounts = Array.from({ length: 3 });

    return (
      <section class={styles.section__container}>
        {/* ProgressBar */}
        <div class={styles.progressBar__container}>
          <div
            class={styles.progressBar__track}
            style={{ width: `${70}%` }}
          ></div>
        </div>

        {/* LifeCounter */}
        <div class={styles.lifeCounter__container}>
          {lifeCounts.map((_, index) => (
            <div class={styles.lifeCounter__item} key={index}>
              ❤️
            </div>
          ))}
        </div>

        <h2 class={styles.title__text}>このポケモンは誰?</h2>
        <img
          src={pokemon.sprites.other.home.front_default ?? ""}
          alt="ポケモン"
          draggable={false}
          class={styles.card__container}
          width={350}
          height={350}
        />
        <div class={styles.displayText__container}>
          {displayTexts.length ? (
            <>
              <span>名前は</span>
              {displayTexts.map((displayText) => (
                <button
                  class={styles.char__button}
                  onClick$={() => onRemoveInput$(displayText)}
                  key={`${displayText.char}-${displayText.index}`}
                >
                  {displayText.char}
                </button>
              ))}
              <span>です</span>
            </>
          ) : (
            <span class={styles.fallback__text}>
              👇 クリックで名前を入力して下さい
            </span>
          )}
        </div>
        <div class={styles.separator} />
        <div class={styles.options__container}>
          {options.map((option) => (
            <button
              class={styles.char__button}
              onClick$={() => onAddInput$(option)}
              key={`${option.char}-${option.index}`}
            >
              {option.char}
            </button>
          ))}
        </div>
        <button onClick$={onAnswer$} class={styles.primary__button}>
          回答する
        </button>
      </section>
    );
  }
);

ルート画面

条件によって画面を切り替える制御処理を追加します。

ページローダーのスタイル
※ ページローダー用のスタイルを src 直下の styles 配下に追加します。

styles/index.module.css
.loader__container {
  position: absolute;
  top: 50%;
  left: 50%;
  height: 10rem;
  width: 10rem;
}

関数を定義するときは $() で囲みます。(参考: The dollar $ sign)

routes/index.tsx
import { $, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { GameStoreType } from "~/features/game/types";
import { Loader } from "~/ui/Loader";
import { GamePlayView } from "~/views/GamePlayView";
import { GameResultView } from "~/views/GameResultView";

import styles from "../styles/index.module.css";

export default component$(() => {
  // NOTE: モック用の固定データ
  const displayTexts = [
    { char: "", index: 1 },
    { char: "", index: 2 },
    { char: "", index: 3 },
    { char: "", index: 4 },
  ];

  const options = [
    { char: "", index: 4 },
    { char: "", index: 1 },
    { char: "", index: 2 },
    { char: "", index: 5 },
    { char: "", index: 3 },
    { char: "", index: 6 },
  ];

  const gameState: GameStoreType = {
    state: "START",
    life: 3,
    score: 0,
    round: 1,
  };

  const isLoading = false;

  // NOTE: $関数でイベントハンドラを定義
  const onAddInput = $(() => {});
  const onAnswer = $(() => {});
  const onRemoveInput = $(() => {});
  const onRetry = $(() => {});

  // NOTE: 早期リターンによる条件分岐
  if (isLoading)
    return (
      <div class={styles.loader__container}>
        <Loader />
      </div>
    );

  if (gameState.state === "END") {
    return <GameResultView onRetry$={onRetry} />;
  }

  return (
    <GamePlayView
      pokemon={{
        id: 1,
        name: "yyy",
        names: [{ language: { name: "ja" }, name: "xxx" }],
        sprites: {
          other: {
            home: {
              front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/132.png",
            },
          },
        },
      }}
      displayTexts={displayTexts}
      options={options}
      onAddInput$={onAddInput}
      onAnswer$={onAnswer}
      onRemoveInput$={onRemoveInput}
    />
  );
});

共通コンポーネントの追加

ui/配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。

CharButton

回答の入力・表示を行うボタンをコンポーネント化します。

ui/CharButton/index.tsx
import { component$ } from "@builder.io/qwik";
import type { QRL } from "@builder.io/qwik";

type CharButtonProps = {
  onClick$: QRL<() => void>;
  children: any;
};
ui/CharButton/index.module.css
.button {
  padding: 1rem 1.5rem;
  background-color: ghostwhite;
  border: none;
  cursor: pointer;
  border-radius: 10px;
}

.button:hover {
  opacity: 0.75;
}

.button:active {
  opacity: 0.5;
}
ui/CharButton/index.tsx
import { component$ } from "@builder.io/qwik";
import type { CharButtonProps } from "./type";
import styles from "./index.module.css";

export const CharButton = component$((props: CharButtonProps) => {
  return (
    <button onClick$={props.onClick$} class={styles.button}>
      {props.children}
    </button>
  );
});

PrimaryButton

回答やリトライを担うプライマリーボタンをコンポーネント化します。

ui/PrimaryButton/index.module.css
.button {
  padding: 1rem 2rem;
  max-width: 100%;
  width: 350px;
  font-weight: 500;
  background-color: greenyellow;
  border: none;
  cursor: pointer;
  border-radius: 10px;
}

.button:hover {
  opacity: 0.75;
}

.button:active {
  opacity: 0.5;
}
ui/PrimaryButton/index.tsx
import { component$ } from "@builder.io/qwik";
import type { QRL } from "@builder.io/qwik";

import styles from "./index.module.css"

type PrimaryButtonProps = {
  onClick$: QRL<() => void>;
  children: any;
};

export const PrimaryButton = component$(
  ({ onClick$, children }: PrimaryButtonProps) => {
    return (
      <button
        onClick$={onClick$}
        class={styles.button}
      >
        {children}
      </button>
    );
  }
);

LifeCounter

残りのライフ表示をコンポーネント化します。

ui/LifeCounter/index.module.css
.container {
  display: flex;
  padding: 0 1rem;
  margin: 1rem;
  column-gap: 0.5rem;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
}

.item {
  font-size: 20px;
}
ui/LifeCounter/index.tsx
import { component$ } from "@builder.io/qwik";

import styles from "./index.module.css"

export const LifeCounter = component$(({ lifeCount }: { lifeCount: number }) => {
  return (
    <div class={styles.container}>
      {Array.from({ length: lifeCount }).map((_, index) => (
        <div class={styles.item} key={index}>
          ❤️
        </div>
      ))}
    </div>
  );
});

Loader

ローディング時に表示するアニメーション SVG のコンポーネント化します。

今回はSVG Backgroundsというサイトで生成したRipplesローダーの色だけ調整してそのまま使用します。

ui/Loader/index.tsx
import { component$ } from "@builder.io/qwik";

export const Loader = component$(() => {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
      <circle
        fill="none"
        stroke-opacity="1"
        stroke="#9E9E9E"
        stroke-width=".5"
        cx="100"
        cy="100"
        r="0"
      >
        <animate
          attributeName="r"
          calcMode="spline"
          dur="2"
          values="1;80"
          keyTimes="0;1"
          keySplines="0 .2 .5 1"
          repeatCount="indefinite"
        ></animate>
        <animate
          attributeName="stroke-width"
          calcMode="spline"
          dur="2"
          values="0;25"
          keyTimes="0;1"
          keySplines="0 .2 .5 1"
          repeatCount="indefinite"
        ></animate>
        <animate
          attributeName="stroke-opacity"
          calcMode="spline"
          dur="2"
          values="1;0"
          keyTimes="0;1"
          keySplines="0 .2 .5 1"
          repeatCount="indefinite"
        ></animate>
      </circle>
    </svg>
  );
});

ProgressBar

ゲームの制限時間表示をコンポーネント化します。

ui/ProgressBar/index.module.css
.container {
  width: 100%;
  height: 0.75rem;
  background-color: #cbff7e;
}

.track {
  height: 100%;
  border-radius: 0 0.375rem 0.375rem 0;
  background-color: yellowgreen;
  transition: width 1s linear;
}
ui/ProgressBar/index.tsx
import { component$ } from "@builder.io/qwik";

import styles from "./index.module.css"

type ProgressBarProps = {
  value: number;
  maxValue: number;
};

export const ProgressBar = component$(({ value, maxValue }: ProgressBarProps) => {
  const progress = (value / maxValue) * 100;

  return (
    <div class={styles.container}>
      <div
        class={styles.track}
        style={{ width: `${progress}%` }}
      ></div>
    </div>
  );
});

その他のコンポーネント

Card

取得したポケモンの画像表示用コンポーネントを実装します。

features/pokemon/ui/Card/index.module.css
.card {
  width: 350px;
  max-width: 100%;
  height: fit-content;
  margin: 0 auto;
}
features/pokemon/ui/Card/index.tsx
import { component$ } from "@builder.io/qwik";

import styles from "./index.modules.css"

export const Card = component$(({ src }: { src: string }) => {
  return (
    <img
      src={src}
      alt="ポケモン"
      draggable={false}
      class={styles.card}
    />
  );
});

共通コンポーネント呼び出し

リザルト画面

PrimaryButton をインポートして使用します。

views/GameResultView/index.tsx
+ import { PrimaryButton } from "~/ui/PrimaryButton";
  import type { GameResultViewProps } from "./type";

  export const GameResultView = component$(
    ({ onRetry$ }: GameResultViewProps) => {
      const { score } = {
        score: 3,
      };
      return (
        <section class={styles.section__container}>
          <div class={styles.score__container}>
            <h2 class={styles.score__label}>スコア</h2>
            <p class={styles.score__value}>
              {score}
              <span class={styles.score__unit}></span>
            </p>
-           <button onClick$={onRetry$} class={styles.primary__button}>
-             もう一度プレイする
-           </button>
+           <PrimaryButton onClick$={onRetry$}>
+             もう一度プレイする
+           </PrimaryButton>
          </div>
        </section>
      );
    }
  );

プレイ画面

共通コンポーネントをインポートして使用します。

views/GamePlayView/index.tsx
+ import { ProgressBar } from "~/ui/ProgressBar";
+ import { LifeCounter } from "~/ui/LifeCounter";
+ import { CharButton } from "~/ui/CharButton";
+ import { PrimaryButton } from "~/ui/PrimaryButton";
+ import { Card } from "~/features/pokemon/ui/Card";

  export const GamePlayView = component$(
    ({
      pokemon,
      options,
      displayTexts,
      onAddInput$,
      onRemoveInput$,
      onAnswer$,
    }: GamePlayViewProps) => {
      return (
        <section class={styles.section__container}>
-         <div class={styles.progressBar__container}>
-           <div class={styles.progressBar__track} style={{ width: `${70}%` }}></div>
-         </div>
+         <ProgressBar value={70} maxValue={100} />

-         <div class={styles.lifeCounter__container}>
-           {lifeCounts.map((_, index) => (
-             <div class={styles.lifeCounter__item} key={index}>❤️</div>
-           ))}
-         </div>
+         <LifeCounter lifeCount={3} />

          <h2 class={styles.title__text}>このポケモンは誰?</h2>
-         <img
-           src={pokemon.sprites.other.home.front_default ?? ""}
-           alt="ポケモン"
-           draggable={false}
-           class={styles.card__container}
-         />
+         <Card src={pokemon.sprites.other.home.front_default ?? ""} />

          {/* ... 他のコード ... */}

-         <button onClick$={onAnswer$} class={styles.primary__button}>
-           回答する
-         </button>
+         <PrimaryButton onClick$={onAnswer$}>
+           回答する
+         </PrimaryButton>
        </section>
      );
    }
  );

最終的なコード

routes/index.tsx
import { $, component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import { GameStoreType } from "~/features/game/types";
import { Loader } from "~/ui/Loader";
import { GamePlayView } from "~/views/GamePlayView";
import { GameResultView } from "~/views/GameResultView";

export default component$(() => {
  const displayTexts = [
    { char: "", index: 1 },
    { char: "", index: 2 },
    { char: "", index: 3 },
    { char: "", index: 4 },
  ];

  const options = [
    { char: "", index: 4 },
    { char: "", index: 1 },
    { char: "", index: 2 },
    { char: "", index: 5 },
    { char: "", index: 3 },
    { char: "", index: 6 },
  ];

  const gameState: GameStoreType = {
    state: "START",
    life: 3,
    score: 0,
    round: 1,
  };

  const isLoading = false;

  const onAddInput = $(() => {});
  const onAnswer = $(() => {});
  const onRemoveInput = $(() => {});
  const onRetry = $(() => {});

  if (isLoading)
    return (
      <div class={styles.loader__container}>
        <Loader />
      </div>
    );

  if (gameState.state === "END") {
    return <GameResultView onRetry$={onRetry} />;
  }

  return (
    <GamePlayView
      pokemon={{
        id: 1,
        name: "yyy",
        names: [{ language: { name: "ja" }, name: "xxx" }],
        sprites: {
          other: {
            home: {
              front_default: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/132.png",
            },
          },
        },
      }}
      displayTexts={displayTexts}
      options={options}
      onAddInput$={onAddInput}
      onAnswer$={onAnswer}
      onRemoveInput$={onRemoveInput}
    />
  );
});
📸 実装イメージ

qwik.png

おわりに

今回共通コンポーネントやページコンポーネント作成を通して、$()QRLをはじめとしたQwikの基本概念が少し分かってきました。

また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします!


この記事は フロントエンドの世界 Advent Calendar 2024の 20 記事目です。
次の記事はこちら Qwik(City)の世界: データ取得と状態管理 #4

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?