実機で壁紙ギャラリーをスクロールしていて、ふとカードを長押ししました。何も起きません。iPhone を使い慣れた人ほど、写真アプリやホーム画面の感覚で「長押しすればプレビューとメニューが出るはず」と手が覚えています。その期待に沈黙で応えてしまうと、アプリが一段安っぽく感じられてしまう。個人開発で壁紙アプリを触っていて、この小さな段差が離脱につながる場面を、私自身、何度も見てきました。
Rork が生成する一覧画面は、多くの場合 Pressable にタップ遷移だけを載せた素朴な作りです。まずはそこから、なぜ長押しメニューだけは一手間必要になるのかを整理します。
Pressable の onLongPress で足りない部分
Pressable には onLongPress が用意されているので、長押しの検知そのものはすぐにできます。
<Pressable onPress={openDetail} onLongPress={() => setMenuOpen(true)}>
<Image source={item.uri} style={styles.card} />
</Pressable>
ただ、これで出せるのは自前で描いたシートやモーダルです。iOS が標準で見せる「対象がふわりと浮き上がり、背景がぼけて、指を離す前にプレビューが見える」あの体験にはなりません。あの浮き上がりと背景ブラーは UIContextMenuInteraction というネイティブの仕組みで、JavaScript 側からビューを重ねても再現しきれない部分です。
つまり判断はこうなります。iOS 利用者に馴染みのある挙動を最優先するなら、ネイティブの context menu を橋渡しするライブラリを使う。細かなアニメーションや Android での見た目を自分で統一したいなら、onLongPress を起点に自前で組む。壁紙アプリのように「保存・共有・お気に入り」という定型の三択を出すだけなら、前者のほうが体験も実装量も有利でした。
二つの実装方針を先に決める
手を動かす前に、両方針の向き不向きを一枚にしておきます。
| 観点 | ネイティブ context menu(zeego) | 自前オーバーレイ(Reanimated) |
| iOS のプレビュー浮き上がり | 標準どおりに出る | 近づけられるが完全一致は難しい |
| Android の見た目統一 | Material のポップアップに従う | 自分で自由に設計できる |
| 実装量 | 少ない | 多い(ジェスチャと座標計算) |
| メニュー項目の動的制御 | やや制約あり | 柔軟 |
壁紙ギャラリーでは、iOS の体感を素直に取りに行きたかったので、まず zeego で組み、Android だけ挙動が気になる箇所を自前に寄せる二段構えにしました。zeego はネイティブの context menu を薄くラップし、対応していない環境では通常のメニューへ自動的に落ちてくれます。迷ったら zeego 起点で始めることを推奨します。
zeego でネイティブメニューを組む
導入は Expo の管理下でそのまま入ります。
npx expo install zeego react-native-ios-context-menu react-native-ios-utilities
カード一つ分をメニュー対応にした最小構成が次です。長押しでメニューが立ち上がり、Preview にプレビューの中身を渡します。
import * as ContextMenu from "zeego/context-menu";
import { Image } from "expo-image";
function WallpaperCard({ item, onSave, onShare, onFavorite }) {
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<Image source={item.thumb} style={styles.card} recyclingKey={item.id} />
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Preview>
{() => <Image source={item.full} style={styles.preview} />}
</ContextMenu.Preview>
<ContextMenu.Item key="save" onSelect={() => onSave(item)}>
<ContextMenu.ItemTitle>写真に保存</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item key="share" onSelect={() => onShare(item)}>
<ContextMenu.ItemTitle>共有</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item key="fav" onSelect={() => onFavorite(item)}>
<ContextMenu.ItemTitle>お気に入り</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
ここで大事なのは、プレビューにサムネイルではなく高解像度の item.full を渡している点です。浮き上がった瞬間に低解像度の画像が拡大されると粗さが目立ち、せっかくの体験が逆効果になります。プレビューは指を離す前のほんの一瞬なので、そのタイミングでだけ大きい画像を読み込む設計が効きます。
スクロールと長押しの競合を解く
段組みギャラリーで最初につまずいた落とし穴が、スクロール中に意図せずメニューが開く問題でした。指を下ろした瞬間を長押しと誤検知してしまうのです。逆に、検知を鈍くしすぎると「長押ししているのに反応しない」不満に変わります。
体感の落としどころは、長押し判定を標準よりわずかに遅らせることでした。トリガー側のラッパーに delayLongPress を持たせて、スクロールの初動と切り分けて回避します。
<ContextMenu.Trigger>
<Pressable delayLongPress={400} android_disableSound>
<Image source={item.thumb} style={styles.card} recyclingKey={item.id} />
</Pressable>
</ContextMenu.Trigger>
私の場合は 400ms 前後が、誤発火とストレスの境目でした。250ms だとスクロール開始で暴発し、600ms を超えると「反応が遅い」と感じます。合わせて、長押しが確定した瞬間に軽いハプティクスを一度だけ返すと、指の下で対象が浮き上がる前の予告になり、誤操作の取り消しもしやすくなります。この一度だけの弱いフィードバックを推奨します。
import * as Haptics from "expo-haptics";
// 長押し確定時に一度だけ
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
ハプティクスは連打しないことが肝心です。メニューが開くたびに強い振動を返すと、かえって安っぽく感じられます。一度きり、弱めに。
保存と共有を実際に動かす
メニュー項目は見た目だけでなく、選んだ後の副作用まで仕上げて初めて完成します。壁紙アプリの中心は「写真に保存」なので、権限の要求と保存を最短で通します。
import * as MediaLibrary from "expo-media-library";
import * as FileSystem from "expo-file-system";
import { Share } from "react-native";
async function saveToPhotos(item) {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== "granted") {
// 拒否時は設定への導線を出す(無言で失敗させない)
return promptOpenSettings();
}
const local = await FileSystem.downloadAsync(
item.full,
FileSystem.cacheDirectory + item.id + ".jpg"
);
await MediaLibrary.saveToLibraryAsync(local.uri);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
async function shareWallpaper(item) {
await Share.share({ url: item.full, message: item.title });
}
保存が拒否されたときに何も起きないのが、体験として最も残念なパターンです。権限が無ければ設定アプリへの導線を出す。保存できたら成功のハプティクスを返す。この二つの対処を添えるだけで、メニューは「押せる飾り」から「使える機能」に変わります。
Android では自前オーバーレイに切り替える
zeego は Android でもメニューを出しますが、iOS のような浮き上がりプレビューにはなりません。壁紙アプリでは「大きく見えること」が価値なので、Android だけプレビュー付きの自前オーバーレイに寄せました。長押しで半透明の背景を敷き、対象を中央に拡大し、下に同じ三択を並べます。
import { Platform } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
function GalleryItem(props) {
if (Platform.OS === "ios") return <WallpaperCard {...props} />;
// Android: onLongPress で自前オーバーレイを開く
return (
<Pressable
delayLongPress={400}
onPress={props.onOpen}
onLongPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
props.openOverlay(props.item);
}}
>
<Image source={props.item.thumb} style={styles.card} recyclingKey={props.item.id} />
</Pressable>
);
}
オーバーレイ本体は画面最上位に一つだけ置き、開いている item を状態で切り替えます。カードごとにオーバーレイを持たせると、リストの仮想化と衝突してメモリが膨らみます。「重い装飾はリストの外に一つ」という原則は、段組みギャラリーを組むときと同じ発想です。列の振り分けやレイアウトの土台については、高さの異なる画像を段組みにする実装にまとめています。
受け入れ基準とまとめ
仕上げの前に、次の三点を実機で確認しています。スクロールを速く続けてもメニューが暴発しないこと。プレビューに高解像度が間に合い、粗く拡大されないこと。保存を拒否したときに設定への導線が出ること。この三つが揃うと、長押しは「隠れた便利」ではなく「触れば分かる自然な操作」になります。
次の一歩として試していただきたいのは、メニュー項目を状況で出し分けることです。すでに保存済みの壁紙なら「保存」を「保存済み」に変える、お気に入りなら星を反転させる。zeego の項目は状態から組み立てられるので、ここまで来ると一覧そのものが静かに賢くなっていきます。
小さな長押し一つですが、iOS 利用者の手が覚えている期待に応えられたときの手応えは、地味ながら確かなものです。実装の参考になれば幸いです。お読みいただきありがとうございました。