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?

React + MUI でイベントアプリの画像 UI を少し使いやすくする

0
Posted at

はじめに

イベントアプリでは、イベント画像を扱う場面がよくある。

ただし、画像機能は大きく作り込まなくても、少しの工夫で使いやすさを上げられる。

今回は、イベント作成画面とイベント一覧カードで使える、画像 UI の小さな改善をまとめる。

扱う内容は以下である。

・イベント作成画面で画像を選択する
・選択中の画像ファイル名を表示する
・選択中の画像を解除できるようにする
・再選択した場合は最後に選んだ画像だけを使う
・画像がないイベントにはローカルのデフォルト画像を表示する
・デフォルト画像は eventId によって固定的に選ぶ

画像プレビューや画像編集機能は扱わない。
あくまで、画面上の操作感とカード表示を軽く整えるための実装メモである。


前提

フロントエンドは以下の構成を想定する。

React
TypeScript
MUI
React Router

画像ファイルは、イベント作成画面で File として state に保持する。

const [eventImage, setEventImage] = useState<File | undefined>();

今回はプレビューを表示しないため、プレビュー URL 用の state は用意しない。


イベント作成画面の画像選択 UI

input を useRef で持つ

画像選択を解除した後、同じ画像を再度選択できるようにするため、input type="file"useRef で参照する。

const eventImageInputRef = useRef<HTMLInputElement | null>(null);

input type="file" は、同じファイルを再度選択した場合に onChange が発火しないことがある。

そのため、選択解除時に input の value も空にする。


画像選択処理

画像が選択されたら、ファイル形式とサイズを軽くチェックしてから state に保存する。

function handleEventImageChange(event: React.ChangeEvent<HTMLInputElement>) {
  const file = event.target.files?.[0];

  if (!file) return;

  const allowedTypes = ["image/jpeg", "image/png"];
  const maxSize = 5 * 1024 * 1024;

  if (!allowedTypes.includes(file.type)) {
    setErrorMessage("jpeg または png の画像を選択してください。");
    return;
  }

  if (file.size > maxSize) {
    setErrorMessage("画像サイズは5MB以下にしてください。");
    return;
  }

  setErrorMessage("");
  setEventImage(file);
}

ここでは、以下を確認している。

・jpeg / png のみ許可
・5MB 以下のみ許可

フロントエンド側のチェックは、ユーザーに早めにエラーを伝えるための補助である。
最終的な検証は、別途サーバー側でも行う必要がある。


選択中の画像を解除する

ユーザーが画像を選んだ後に、「やはり画像なしで作成したい」と思う場合がある。

そのため、選択中の画像を解除できるようにする。

function handleCancelEventImage() {
  setEventImage(undefined);

  if (eventImageInputRef.current) {
    eventImageInputRef.current.value = "";
  }
}

setEventImage(undefined) によって、選択中の画像を state から消す。

さらに、以下で file input の値も空にする。

eventImageInputRef.current.value = "";

これにより、解除後に同じ画像を再び選択しても onChange が発火しやすくなる。


画像選択 UI

MUI の Button を使って画像を選択する。

画像が選択されている場合は、ファイル名と小さな解除ボタンを表示する。

import CloseIcon from "@mui/icons-material/Close";
import { Button, IconButton, Stack, Typography } from "@mui/material";
import { useRef, useState } from "react";

UI は以下のようにする。

<Stack spacing={1}>
  <Button component="label" variant="outlined">
    イベント画像を選択
    <input
      ref={eventImageInputRef}
      hidden
      type="file"
      accept="image/jpeg,image/png"
      onChange={handleEventImageChange}
    />
  </Button>

  {eventImage && (
    <Stack direction="row" spacing={0.5} alignItems="center">
      <Typography variant="body2" color="text.secondary">
        {eventImage.name}
      </Typography>

      <IconButton
        size="small"
        onClick={handleCancelEventImage}
        aria-label="画像選択を解除"
        sx={{ p: 0.3 }}
      >
        <CloseIcon fontSize="small" />
      </IconButton>
    </Stack>
  )}
</Stack>

通常の Button で「選択解除」と置くと、少し重く見える。
ファイル名の横に小さな × を置くと、UI の主張を抑えながら解除機能を持たせられる。


再選択時は最後の画像だけを使う

イベント作成前の画像は、まだブラウザ上の File として state に保持されているだけである。

そのため、ユーザーが画像を選び直した場合は、単純に state を上書きすればよい。

setEventImage(file);

例えば、最初に A 画像を選び、その後 B 画像を選んだ場合、最終的に使われるのは B 画像だけである。

1回目の選択:
  eventImage = A

2回目の選択:
  eventImage = B

作成時:
  B だけを使う

作成前の画像はまだ保存されていないため、再選択しても保存先には影響しない。


イベントカードで画像を表示する

イベント一覧画面では、イベント画像をカード上部に表示する。

MUI の CardMedia を使うと簡潔に実装できる。


EventCard 型に eventImageUrl を追加する

イベント一覧用の型に eventImageUrl を追加する。

export type EventCard = {
  id: number;
  title: string;
  type: string;
  eventStatus?: string;
  eventStartTime: string;
  location: string;
  tags?: Tag[];
  eventImageUrl?: string;
};

画像 URL がある場合はその画像を表示し、ない場合はローカルのデフォルト画像を表示する。


CardMedia を使う

EventListCard.tsxCardMedia を import する。

import {
  Box,
  Card,
  CardActionArea,
  CardContent,
  CardMedia,
  Stack,
  Typography,
} from "@mui/material";

カードの構造は以下のようにする。

<Card>
  <CardActionArea>
    <CardMedia />
    <CardContent>
      イベント情報
    </CardContent>
  </CardActionArea>
</Card>

画像は CardContent の上に置く。

<CardActionArea
  component={RouterLink}
  to={`/events/${eventCard.id}`}
  sx={{
    height: "100%",
    display: "flex",
    flexDirection: "column",
    alignItems: "stretch",
  }}
>
  <CardMedia
    component="img"
    image={eventCard.eventImageUrl}
    alt={eventCard.title}
    sx={{
      width: "100%",
      height: 150,
      objectFit: "cover",
    }}
  />

  <CardContent>
    {/* イベント情報 */}
  </CardContent>
</CardActionArea>

objectFit: "cover" を指定することで、画像の縦横比を保ちながら、カード上部の領域に自然に収められる。


画像がないイベントにはローカルのデフォルト画像を表示する

すべてのイベントに画像が設定されているとは限らない。

画像がない場合に No Image と表示する方法もあるが、イベント一覧画面の見た目を整えるため、今回はローカルに用意したデフォルト画像を表示する。


public ディレクトリにデフォルト画像を置く

例えば、以下のように画像を置く。

public/default-events/default-event-1.jpg
public/default-events/default-event-2.jpg
public/default-events/default-event-3.jpg
public/default-events/default-event-4.jpg

Vite では、public 配下のファイルはルート相対パスで参照できる。

/default-events/default-event-1.jpg

src/assets に置いて import する方法もあるが、単純なデフォルト画像であれば public に置く方が扱いやすい。


Math.random は使わない

デフォルト画像をランダムに出したくなるが、以下のような実装は避ける。

const image =
  defaultEventImages[Math.floor(Math.random() * defaultEventImages.length)];

この書き方では、コンポーネントの再描画やページ再読み込みのたびに画像が変わる可能性がある。

同じイベントなのに表示される画像が毎回変わると、一覧画面としては少し不安定に見える。


eventId でデフォルト画像を固定する

そこで、eventId を使ってデフォルト画像を選ぶ。

const defaultEventImages = [
  "/default-events/default-event-1.jpg",
  "/default-events/default-event-2.jpg",
  "/default-events/default-event-3.jpg",
  "/default-events/default-event-4.jpg",
];

function getDefaultEventImage(eventId: number): string {
  return defaultEventImages[
    Math.abs(eventId * 31) % defaultEventImages.length
  ];
}

eventId が同じであれば、常に同じデフォルト画像が選ばれる。

また、eventId が増えても問題ない。

例えば、デフォルト画像が 4 枚ある場合、% defaultEventImages.length によって、用意した画像の中から循環して選ばれる。

eventId = 1    -> どれか1枚
eventId = 10   -> どれか1枚
eventId = 100  -> どれか1枚
eventId = 1000 -> どれか1枚

eventId * 31 としているのは、連番の eventId でも選ばれ方を少し分散させるためである。

単純に以下のように書いてもよい。

eventId % defaultEventImages.length

eventImageUrl がなければデフォルト画像を使う

表示時には、eventImageUrl があればアップロード画像を使い、なければデフォルト画像を使う。

<CardMedia
  component="img"
  image={eventCard.eventImageUrl || getDefaultEventImage(eventCard.id)}
  alt={eventCard.title}
  sx={{
    width: "100%",
    height: 150,
    objectFit: "cover",
  }}
/>

これにより、画像が登録されているイベントでは登録済み画像が表示され、画像がないイベントではローカルのデフォルト画像が表示される。


EventListCard の実装例

全体としては、以下のように実装できる。

import {
  Box,
  Card,
  CardActionArea,
  CardContent,
  CardMedia,
  Stack,
  Typography,
} from "@mui/material";
import { Link as RouterLink } from "react-router-dom";

const defaultEventImages = [
  "/default-events/default-event-1.jpg",
  "/default-events/default-event-2.jpg",
  "/default-events/default-event-3.jpg",
  "/default-events/default-event-4.jpg",
];

function getDefaultEventImage(eventId: number): string {
  return defaultEventImages[
    Math.abs(eventId * 31) % defaultEventImages.length
  ];
}

type EventCard = {
  id: number;
  title: string;
  type: string;
  eventStatus?: string;
  eventStartTime: string;
  location: string;
  tags?: Tag[];
  eventImageUrl?: string;
};

export function EventListCard(eventCard: EventCard): JSX.Element {
  return (
    <Card
      elevation={2}
      sx={{
        width: "100%",
        minHeight: 260,
        borderRadius: 3,
        display: "flex",
        flexDirection: "column",
        transition: "0.2s",
        "&:hover": {
          transform: "translateY(-2px)",
          boxShadow: 6,
        },
      }}
    >
      <CardActionArea
        component={RouterLink}
        to={`/events/${eventCard.id}`}
        sx={{
          height: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "stretch",
        }}
      >
        <CardMedia
          component="img"
          image={eventCard.eventImageUrl || getDefaultEventImage(eventCard.id)}
          alt={eventCard.title}
          sx={{
            width: "100%",
            height: 150,
            objectFit: "cover",
          }}
        />

        <CardContent
          sx={{
            width: "100%",
            flexGrow: 1,
            display: "flex",
            flexDirection: "column",
          }}
        >
          <Stack
            direction="row"
            spacing={2}
            sx={{
              alignItems: "center",
              justifyContent: "space-between",
              mb: 1,
            }}
          >
            <Typography variant="body2" color="text.secondary">
              {eventCard.type}
            </Typography>

            {eventCard.eventStatus && (
              <Typography variant="body2" color="primary">
                {eventCard.eventStatus}
              </Typography>
            )}
          </Stack>

          <Typography
            variant="h5"
            component="h2"
            sx={{
              fontWeight: "bold",
              mb: 1,
              lineHeight: 1.35,
            }}
          >
            {eventCard.title}
          </Typography>

          <Box sx={{ mt: 1, mb: 2 }}>
            {/* タグ表示など */}
          </Box>

          <Box sx={{ mt: "auto" }}>
            <Stack spacing={0.8}>
              <Typography variant="body2" color="text.secondary">
                {eventCard.eventStartTime}
              </Typography>

              <Typography variant="body2" color="text.secondary">
                {eventCard.location}
              </Typography>
            </Stack>
          </Box>
        </CardContent>
      </CardActionArea>
    </Card>
  );
}

実装時の確認ポイント

選択解除後に同じ画像を選べるか

選択解除時に、state だけでなく input の value も空にしているか確認する。

if (eventImageInputRef.current) {
  eventImageInputRef.current.value = "";
}

これを行わない場合、同じ画像を再度選択しても onChange が発火しないことがある。


画像のパスを確認する

デフォルト画像が表示されない場合は、画像の配置場所とパスを確認する。

配置:

public/default-events/default-event-1.jpg

参照:

/default-events/default-event-1.jpg

public 配下のファイルは、/ から始まるルート相対パスで指定する。


カードの高さを揃える

一覧画面では、画像本来のサイズに合わせるより、カード側で高さを固定した方がレイアウトが安定する。

sx={{
  width: "100%",
  height: 150,
  objectFit: "cover",
}}

height を固定し、objectFit: "cover" を指定することで、画像サイズが異なってもカードの見た目を揃えやすくなる。


まとめ

今回は、イベントアプリの画像まわりで使える小さな UI 改善を実装した。

実装した内容は以下である。

・イベント作成時に画像ファイルを選択する
・選択中のファイル名を表示する
・小さな × ボタンで選択解除できるようにする
・再選択した場合は最後に選んだ画像だけを使う
・イベントカード上部に画像を表示する
・画像がないイベントにはローカルのデフォルト画像を表示する
・デフォルト画像は eventId によって固定的に選ぶ

画像機能は、複雑な編集機能やプレビューを入れなくても、少しの工夫で使いやすくできる。

選択解除、ファイル名表示、デフォルト画像のような小さな改善を入れるだけでも、イベント作成画面と一覧画面の使いやすさはかなり安定する。

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?