要素
Dialog
Dialogは、アプリの他の部分の上に、独自のネイティブなウィンドウレイヤーでコンテンツを表示します。iOSとMac CatalystではPresented view controller、AndroidではDialog、デスクトップとWebではウィンドウ全体を覆うレイヤーが使われます。全画面フロー、シート、ダイアログに使用します。システム標準のジェスチャー(シートを下にスワイプする、Androidの戻るボタン、Escape)も動作し、ネイティブ側で処理されます。Rustではnsh_base::dialog()、TSXではnsh/baseの<Dialog>で作成します。
import { mount, signal, Text, View } from "nsh";
import { Button, Dialog, Switch } from "nsh/base";
import { button, buttonText, desktop, Inspector, Panel, Stage, tint, ui, wide } from "./palette";
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";
function App() {
const card = signal(false);
const sheet = signal(false);
const backdropCloses = signal(true);
const cancels = signal(0);
const log = signal("–");
const openCard = () => (
<Button onPress={() => card.set(true)} {...button("primary")}>
<Text style={buttonText("primary")}>Open a card</Text>
</Button>
);
const openSheet = () => (
<Button onPress={() => sheet.set(true)} {...button()}>
<Text style={buttonText()}>Open as a page sheet</Text>
</Button>
);
const backdrop = () => (
<Switch
checked={backdropCloses()}
onChange={({ checked }) => backdropCloses.set(checked)}
class={wide() ? "flex-row items-center gap-2 self-center active:opacity-70" : "flex-row items-center gap-3 rounded-xl p-3 active:opacity-70"}
style={wide() ? undefined : ui().row}
trackClass={track}
trackStyle={backdropCloses() ? ui().accentFill : ui().inset}
thumbClass={thumb}
thumbStyle={backdropCloses() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ color: ui().text, flex: wide() ? undefined : 1, fontSize: desktop ? 13 : undefined }}>closeOnBackdropPress {String(backdropCloses())}</Text>
</Switch>
);
const hint = desktop
? "Close with Escape or a click on the backdrop."
: "Close with a tap on the backdrop, a swipe down or the back button. On iOS the sheet is a system pageSheet.";
const state = () => `open: card ${String(card())} · sheet ${String(sheet())} · onCancel ${cancels()}×`;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>dialog</Text>
{() =>
wide() ? (
<>
<Text style={{ color: ui().muted }}>{hint}</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage gap={16}>
<View style={{ flexDirection: "row", justifyContent: "center", gap: 8 }}>
{openCard()}
{openSheet()}
</View>
{backdrop()}
</Stage>
<Inspector>
<Panel title="open · onCancel">
<Text style={{ fontSize: 13, color: ui().text }}>{state()}</Text>
</Panel>
<Panel title="onOpenChange({ open, reason }) · onOpen" grow>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().accent }}>{log()}</Text>
</Panel>
</Inspector>
</View>
</>
) : (
<View style={{ gap: 10 }}>
{openCard()}
{openSheet()}
{backdrop()}
<View style={{ ...ui().inset, borderRadius: 12, padding: 14, gap: 4 }}>
<Text style={{ color: ui().muted, fontSize: 13 }}>{state()}</Text>
<Text style={{ color: ui().accent, fontSize: 15, fontWeight: "600" }}>{log()}</Text>
</View>
<Text style={{ color: ui().faint, fontSize: 13 }}>{hint}</Text>
</View>
)
}
<Dialog
open={card()}
animation="fade"
closeOnBackdropPress={backdropCloses()}
onOpenChange={({ open, reason }) => {
card.set(open);
log.set(`onOpenChange({ open: ${open}, reason: "${reason}" })`);
}}
onCancel={() => cancels.update((n) => n + 1)}
onOpen={() => log.set("onOpen: card")}
backdropTestID="card-backdrop"
backdropStyle={{ backgroundColor: tint("#000000", 0.45) }}
style={{ ...ui().card, margin: "auto", width: 320, maxWidth: "90%", borderRadius: 18, padding: 20, gap: 12 }}
>
<Text style={{ color: ui().text, fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
<Text style={{ color: ui().muted }}>
{backdropCloses()
? "A press on the dimmed backdrop closes it."
: "The backdrop only calls onCancel: close with a button."}
</Text>
<View style={{ flexDirection: "row", gap: 8, justifyContent: desktop ? "flex-end" : undefined }}>
<Button
onPress={() => {
card.set(false);
log.set("closed by the app: Keep");
}}
{...button("secondary", { style: desktop ? {} : { flex: 1 } })}
>
<Text style={buttonText()}>Keep</Text>
</Button>
<Button
onPress={() => {
card.set(false);
log.set("closed by the app: Delete");
}}
{...button("danger", { style: desktop ? {} : { flex: 1 } })}
>
<Text style={buttonText("danger")}>Delete</Text>
</Button>
</View>
</Dialog>
<Dialog
open={sheet()}
presentation="pageSheet"
onOpenChange={({ open, reason }) => {
sheet.set(open);
log.set(`onOpenChange({ open: ${open}, reason: "${reason}" })`);
}}
onCancel={() => cancels.update((n) => n + 1)}
onOpen={() => log.set("onOpen: sheet")}
backdropStyle={{ backgroundColor: tint("#000000", 0.3) }}
// As tall as its content: a panel in the middle on the desktop, a
// sheet along the bottom edge on a phone.
style={
desktop
? { backgroundColor: ui().page, margin: "auto", width: 480, maxWidth: "90%", maxHeight: "90%", borderRadius: 16 }
: {
backgroundColor: ui().page,
marginTop: "auto",
width: "100%",
maxWidth: 560,
maxHeight: "90%",
alignSelf: "center",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}
}
>
<View style={{ padding: 20, paddingTop: "calc(env(safe-area-inset-top) + 20px)", paddingRight: "calc(env(safe-area-inset-right) + 20px)", paddingBottom: "calc(env(safe-area-inset-bottom) + 20px)", paddingLeft: "calc(env(safe-area-inset-left) + 20px)", gap: 12 }}>
<Text style={{ color: ui().text, fontSize: 22, fontWeight: "700" }}>A sheet</Text>
<Text style={{ color: ui().muted }}>
presentation="pageSheet": a system sheet on iOS that follows the finger when swiped down. Elsewhere the
dialog is a layer over the window, and this panel is the app's own style.
</Text>
<Button onPress={() => sheet.set(false)} {...button("primary", { align: "flex-end" })}>
<Text style={buttonText("primary")}>Close</Text>
</Button>
</View>
</Dialog>
</View>
);
}
mount(App);Dialogは動作を自身で管理し、見た目はアプリに任せます。そのスタイルはポップアップ(子要素を保持するビュー)に適用され、TSXではbackdropClass / backdropStyleでその背後にバックドロップを追加できます。
use nsh_base::{button, dialog};
use nsh_core::prelude::*;
/// A blue button with a white label.
fn primary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.bg(rgb(0x2563eb))
.px_4()
.py_3()
.active(|s| s.opacity(0.7))
.child(text(label).font_weight(FontWeight::SEMIBOLD).text_color(white()))
}
fn example() -> impl IntoElement {
let open = signal(false);
view()
.flex_1()
.justify_center()
.p_6()
.child(primary("Open", move || open.set(true)))
.child(when(open, move || {
dialog()
.presentation(ModalPresentation::PageSheet)
.on_cancel(move || open.set(false))
.flex_1()
.bg(white())
.child(
view()
.flex_1()
.px_5()
.pt_safe_area_plus(px(20.0))
.pb_safe_area_plus(px(20.0))
.gap_3()
.child(text("A sheet").text_2xl().font_weight(FontWeight::BOLD))
.child(primary("Close", move || open.set(false))),
)
}))
}import { signal, Text, View } from "nsh";
import { Button, Dialog } from "nsh/base";
function Example() {
const open = signal(false);
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24 }}>
<Button onPress={() => open.set(true)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Open</Text>
</Button>
<Dialog open={open()} presentation="pageSheet" onOpenChange={(e) => open.set(e.open)} class="flex-1 bg-white">
<View
style={{
flex: 1,
gap: 12,
paddingHorizontal: 20,
paddingTop: "calc(env(safe-area-inset-top) + 20px)",
paddingBottom: "calc(env(safe-area-inset-bottom) + 20px)",
}}
>
<Text style={{ fontSize: 24, fontWeight: "700" }}>A sheet</Text>
<Button onPress={() => open.set(false)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Close</Text>
</Button>
</View>
</Dialog>
</View>
);
}TSXでは、openがfalseでない間はDialogが表示され、falseになると閉じます。Rustでは、ビューに含まれている間は表示され、ビューから外れると閉じるため、whenがそれを保持します。いずれの場合も、Dialogのセーフエリアのインセットがコンテンツに渡され、その内側にあるenv(safe-area-inset-*)変数とsafeAreaInsets()(Rustコンポーネントではcx.insets())がそれを読み取ります。同じくnsh/baseからインポートするButtonは、アプリがスタイルを設定する押下ターゲットです。
nsh_core::preludeには、非推奨の古いdialog関数もあります。上記のようにnsh_base::dialogを名前でインポートすると、明示的なインポートの方が優先されます。システムのアラートやアクションシートには、AlertDialogを使用してください。
#Props
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
open | 開いている間ビューに含まれる(when(open, ..)) | boolean | true | trueの間表示されます。 |
animation | .animation(ModalAnimation::Fade) | "slide" | "fade" | "none" | "slide" | 表示と非表示のアニメーション方法。 |
transitionShader | .transition_shader(TransitionShader::new(program)) | { source: string; uniforms?: Record<string, ShaderUniform>; duration?: number } | なし | animationの代わりに、WGSLシェーダーを通して表示・非表示を行います。durationミリ秒(デフォルトは350)かけてnsh.progressが0から1へ進む間、shade(uv)は背後の内容をsample_from、Dialogをsample_toとして読み取り、閉じるときは1から0へ戻ります。システムがモーションの軽減を求めている場合は、代わりにanimationが使われます。Shaderを参照してください。 |
presentation | .presentation(ModalPresentation::PageSheet) | "fullScreen" | "pageSheet" | "formSheet" | "fullScreen" | iOSでの形式。fullScreenは透明な背景で画面全体を覆います。pageSheetとformSheetは背後が見えたままになるシステムシートです(formSheetは大きな画面で中央に表示される小さめのシートです)。 |
closeOnEscape | .close_on_escape(..) | boolean | true | EscapeとAndroidの戻るボタンでネイティブに閉じます。 |
closeOnBackdropPress | .close_on_backdrop_press(..) | boolean | true | バックドロップを押すと閉じます。 |
closeOnSwipe | .close_on_swipe(..) | boolean | true | シートを下にスワイプ(iOS、スマートフォンのブラウザー)すると、ネイティブに閉じます。 |
statusBar | .status_bar(..) | { foreground?, hidden?, animated? } | なし | iOSとAndroid: Dialogが開いている間のステータスバーで、各ページの設定より優先されます(Status bar)。 |
onOpen | .on_open(..) | () => void | 表示が完了しました。 | |
onClose | .on_close(..) | () => void | 消えました。ユーザーが閉じたか、アプリが削除しました。 | |
onCancel | .on_cancel(..) | () => void | ユーザーが閉じようとしました(Escape、戻る、バックドロップの押下、下スワイプ)。closeOn*フラグで閉じることが許可されているかどうかに関わらず呼ばれます。 | |
onOpenChange | .on_cancel(..) | (e: { open: boolean; reason: "cancel" | "backdropPress" | "swipe" }) => void | ユーザーが閉じました。ここでopenを同期させてください。Rustでは、on_cancelがDialogをビューから削除します。 | |
class, style | Styledメソッド | string, StyleProp<ViewStyle> | ポップアップ。子要素を保持するビューです。 | |
backdropClass, backdropStyle | Dialogを埋める子ビュー | string, StyleProp<ViewStyle> | ポップアップの背後でDialogを埋めるバックドロップ。どちらかが設定されている場合に存在します。 | |
children | .child(..) | JSX.Element | コンテンツ。 |
iosのキー | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
supportedOrientations | .supported_orientations(..) | ("portrait" | "portrait-upside-down" | "landscape" | "landscape-left" | "landscape-right")[] | 提示元の設定 | Dialogが許可する画面の向き。 |
androidのキー | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
statusBarTranslucent | .status_bar_translucent(..) | boolean | false | コンテンツがステータスバーの下まで広がります。それ以外の場合は、ステータスバーの下から始まります。 |
#Opening and closing
Dialogの状態はアプリが保持します。ユーザーがDialogを閉じると(Escape、Androidの戻るボタン、バックドロップの押下、シートの下スワイプ)、ネイティブ側で閉じてアプリに通知し、アプリは状態をそれに合わせて設定します。TSXではonOpenChange({ open: false, reason })、Rustではon_cancelです。TSXでは、openがtrueのままの間はDialogは閉じたままで、openがfalseからtrueに変わると再び開きます。
各closeOn*フラグは、ユーザーの操作時にネイティブ側で読み取られます。メインスレッドがアプリのコードを待つことはないためです。閉じる前に確認したい場合は、フラグをfalseに設定してください。すると、閉じようとする操作でonCancelが呼ばれ、アプリ自身がDialogを閉じます(TSXではopenをfalseに設定し、Rustではビューから取り除きます)。
dialog()
.presentation(ModalPresentation::PageSheet)
.close_on_escape(false)
.close_on_backdrop_press(false)
.close_on_swipe(false)
.on_cancel(move || ask_to_discard.set(true))
.flex_1()
.bg(white())
.child(form())<Dialog
open={open()}
presentation="pageSheet"
closeOnEscape={false}
closeOnBackdropPress={false}
closeOnSwipe={false}
onCancel={() => askToDiscard.set(true)}
class="flex-1 bg-white"
>
…
</Dialog>onCancelは、フラグでDialogを閉じられるかどうかに関わらず、閉じようとするたびに呼ばれます。
#A dialog with a backdrop
デフォルトのfullScreenプレゼンテーションのDialogは、アプリが描画したものを表示します。ここでは、暗くしたバックドロップと、その上に中央配置されたカードです。バックドロップを押すと閉じます。
when(confirm, move || {
dialog()
.animation(ModalAnimation::Fade)
.on_cancel(move || confirm.set(false))
.flex_1()
.child(
// The backdrop: a press on it closes the dialog.
view()
.absolute()
.inset_0()
.bg(rgba(0x00000066))
.on_press(move || confirm.set(false)),
)
.child(
view()
.m(auto())
.w_80()
.gap_3()
.rounded_2xl()
.bg(white())
.p_5()
.child(text("Delete this item?").text_xl().font_weight(FontWeight::BOLD))
.child(
button()
.on_press(remove)
.items_center()
.rounded_lg()
.bg(rgb(0xdc2626))
.px_4()
.py_3()
.active(|s| s.opacity(0.7))
.child(text("Delete").font_weight(FontWeight::SEMIBOLD).text_color(white())),
),
)
})<Dialog
open={confirm()}
animation="fade"
onOpenChange={({ open }) => confirm.set(open)}
backdropClass="bg-black/40"
class="m-auto w-80 gap-3 rounded-2xl bg-white p-5"
>
<Text style={{ fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
<Button onPress={remove} class="items-center rounded-lg bg-red-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Delete</Text>
</Button>
</Dialog>システムのアラートやアクションシートには、nsh/baseのAlertDialogを使用してください。
#Ref methods
なし。
#Platform notes
- iOSとMac Catalyst:
presentationで形式を選びます。fullScreenは透明な背景で画面全体に表示します。pageSheetとformSheetはシステム背景のシステムシートで、下にスワイプすると指に追従します(closeOnSwipe)。 - Android: Dialogはウィンドウ全体を埋め、アプリのポップアップとバックドロップを表示します。戻るボタンは、スタックや
BackHandlerリスナーより先に、最前面のDialogに渡されます。 - デスクトップ(macOS、Windows、Linux、GPUI)とWeb: Dialogはウィンドウ全体を覆う透明なレイヤーで、アプリのポップアップとバックドロップを表示します。Escapeは閉じようとする操作として扱われます。
supportedOrientationsはiOS用、statusBarTranslucentはAndroid用です。Androidでは、statusBarTranslucentを設定しない限り、Dialogのコンテンツはステータスバーの下から始まります。