はじめに
イベントアプリでは、イベント画像を扱う場面がよくある。
ただし、画像機能は大きく作り込まなくても、少しの工夫で使いやすさを上げられる。
今回は、イベント作成画面とイベント一覧カードで使える、画像 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.tsx で CardMedia を 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 によって固定的に選ぶ
画像機能は、複雑な編集機能やプレビューを入れなくても、少しの工夫で使いやすくできる。
選択解除、ファイル名表示、デフォルト画像のような小さな改善を入れるだけでも、イベント作成画面と一覧画面の使いやすさはかなり安定する。