diff --git a/crates/component/src/dialog/alert_dialog.rs b/crates/component/src/dialog/alert_dialog.rs index 7f68e69717..a96e56d310 100644 --- a/crates/component/src/dialog/alert_dialog.rs +++ b/crates/component/src/dialog/alert_dialog.rs @@ -7,7 +7,8 @@ use crate::{ StyledExt as _, WindowExt as _, button::ButtonVariant, dialog::{ - Dialog, DialogButtonProps, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + Dialog, DialogButtonProps, DialogDescription, DialogEntrance, DialogFooter, DialogHeader, + DialogTitle, }, h_flex, v_flex, }; @@ -270,6 +271,14 @@ impl AlertDialog { self } + /// Sets how the alert dialog enters, defaulting to [`DialogEntrance::SlideDown`]. + /// + /// Shares the surface, backdrop, and reduced-motion policy of [`Dialog::entrance`]. + pub fn entrance(mut self, entrance: DialogEntrance) -> Self { + self.base = self.base.entrance(entrance); + self + } + /// Sets the callback for when the alert dialog is closed. /// /// Called after [`Self::on_action`] or [`Self::on_cancel`] callback. diff --git a/crates/component/src/dialog/dialog.rs b/crates/component/src/dialog/dialog.rs index 839aca5f14..5a05281f2e 100644 --- a/crates/component/src/dialog/dialog.rs +++ b/crates/component/src/dialog/dialog.rs @@ -1,3 +1,4 @@ +use super::entrance::{DialogEntrance, EntranceSurface}; use crate::root::WindowState; use gpui_base::TestSupportExt as _; use std::{rc::Rc, sync::LazyLock, time::Duration}; @@ -210,6 +211,7 @@ pub(crate) struct DialogProps { overlay_closable: bool, pub(crate) overlay_visible: bool, keyboard: bool, + entrance: DialogEntrance, } impl Default for DialogProps { @@ -223,6 +225,7 @@ impl Default for DialogProps { overlay_visible: false, close_button: true, overlay_closable: true, + entrance: DialogEntrance::default(), } } } @@ -324,6 +327,25 @@ pub(crate) fn overlay_color(overlay: bool, cx: &App) -> Hsla { cx.theme().overlay } +fn dialog_shadow(delta: f32) -> Vec { + vec![ + BoxShadow { + color: hsla(0., 0., 0., 0.1 * delta), + offset: point(px(0.), px(20.)), + blur_radius: px(25.), + spread_radius: px(-5.), + inset: false, + }, + BoxShadow { + color: hsla(0., 0., 0., 0.1 * delta), + offset: point(px(0.), px(8.)), + blur_radius: px(10.), + spread_radius: px(-6.), + inset: false, + }, + ] +} + impl Dialog { /// Create a new dialog. pub fn new(cx: &mut App) -> Self { @@ -488,6 +510,15 @@ impl Dialog { self } + /// Sets how the dialog enters, defaulting to [`DialogEntrance::SlideDown`]. + /// + /// This controls the surface and backdrop together. Reduced motion skips + /// the entrance and immediately shows the final state. Closing remains immediate. + pub fn entrance(mut self, entrance: DialogEntrance) -> Self { + self.props.entrance = entrance; + self + } + pub(crate) fn has_overlay(&self) -> bool { self.props.overlay } @@ -576,6 +607,12 @@ impl RenderOnce for Dialog { let margin = cx.theme().spacing_tokens().lg; let layer_offset = px(layer_ix as f32 * 16.); let y = self.props.margin_top.unwrap_or(view_size.height / 10.) + layer_offset; + let entrance = self.props.entrance; + let motion = cx.theme().motion_tokens(); + let animated = entrance != DialogEntrance::None + && !cx.reduce_motion() + && (entrance == DialogEntrance::SlideDown || !motion.duration_normal.is_zero()); + let travel = motion.distance_short.to_pixels(window.rem_size()); let width = self .props .width @@ -604,12 +641,17 @@ impl RenderOnce for Dialog { // preserving the trajectory this dialog was tuned with before // `cubic_bezier` solved for x; vaul's (0.32, 0.72, 0., 1.) is far // more front-loaded under the CSS-correct solver. - let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier( - 1. / 3., - 0.72, - 2. / 3., - 1., - )); + let animation = if entrance == DialogEntrance::SlideDown { + Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier( + 1. / 3., + 0.72, + 2. / 3., + 1., + )) + } else { + let easing = motion.easing_enter.clone(); + Animation::new(motion.duration_normal).with_easing(move |delta| easing.sample(delta)) + }; anchored() .position(point(window_paddings.left, window_paddings.top)) @@ -749,45 +791,77 @@ impl RenderOnce for Dialog { .icon(IconName::Close) }) })) - .with_animation( - "slide-down-shadow", - animation.clone(), - move |this, delta| { - // This is equivalent to `shadow_xl` with an extra opacity. - let shadow = vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1 * delta), - offset: point(px(0.), px(20.)), - blur_radius: px(25.), - spread_radius: px(-5.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1 * delta), - offset: point(px(0.), px(8.)), - blur_radius: px(10.), - spread_radius: px(-6.), - inset: false, + .map(|surface| { + if animated { + surface + .with_animation( + "slide-down-shadow", + animation.clone(), + |surface, delta| { + surface.shadow(dialog_shadow(delta)) + }, + ) + .text_selection_scope(selection_scope) + .into_any_element() + } else { + surface + .shadow(dialog_shadow(1.)) + .text_selection_scope(selection_scope) + .into_any_element() + } + }) + .map(|surface| { + if animated && entrance == DialogEntrance::FadeSlide { + EntranceSurface::new( + surface, + travel, + window_paddings.top + margin, + ) + .with_animation( + "dialog-settle", + animation.clone(), + |mut surface, delta| { + surface.progress = delta; + surface }, - ]; - this.shadow(shadow) - }, - ) - .text_selection_scope(selection_scope), + ) + .into_any_element() + } else { + surface + } + }), ) - .with_animation( - "slide-down", - animation.clone(), - move |this, delta| { - this.position(point( - window_paddings.left + x, - window_paddings.top + y * delta, - )) - }, - ), + .map(|positioner| { + if animated && entrance == DialogEntrance::SlideDown { + positioner + .with_animation( + "slide-down", + animation.clone(), + move |this, delta| { + this.position(point( + window_paddings.left + x, + window_paddings.top + y * delta, + )) + }, + ) + .into_any_element() + } else { + positioner.into_any_element() + } + }), ), ) - .with_animation("fade-in", animation, move |this, delta| this.opacity(delta)), + .map(|layer| { + if animated { + layer + .with_animation("fade-in", animation, |layer, delta| { + layer.opacity(delta) + }) + .into_any_element() + } else { + layer.into_any_element() + } + }), ) .into_any_element() } @@ -925,4 +999,100 @@ pub(crate) mod tests { assert!(second.bottom() <= viewport.height - px(16.), "{second:?}"); assert!(second.size.height < first.size.height); } + + #[gpui::test] + fn test_dialog_entrance_builder(cx: &mut TestAppContext) { + cx.update(crate::init); + cx.update(|cx| { + assert_eq!(Dialog::new(cx).props.entrance, DialogEntrance::SlideDown); + for entrance in [ + DialogEntrance::SlideDown, + DialogEntrance::Fade, + DialogEntrance::FadeSlide, + DialogEntrance::None, + ] { + assert_eq!(Dialog::new(cx).entrance(entrance).props.entrance, entrance); + } + }); + } + + #[gpui::test] + fn reduced_motion_displays_every_entrance_at_rest(cx: &mut TestAppContext) { + for entrance in [ + DialogEntrance::SlideDown, + DialogEntrance::Fade, + DialogEntrance::FadeSlide, + DialogEntrance::None, + ] { + let cx = window(cx, size(px(1000.), px(800.))); + open(cx, move |dialog, _, _| { + dialog + .title("Entrance") + .child("body") + .entrance(entrance) + .margin_top(px(300.)) + }); + assert_eq!(surface(cx, 0).top(), px(300.)); + } + } + + #[gpui::test] + fn no_entrance_displays_immediately_and_keeps_keyboard_dismissal(cx: &mut TestAppContext) { + let cx = window(cx, size(px(1000.), px(800.))); + cx.update(|_, cx| cx.set_reduce_motion(false)); + open(cx, |dialog, _, _| { + dialog + .title("Immediate") + .child("body") + .entrance(DialogEntrance::None) + .margin_top(px(300.)) + }); + assert_eq!(surface(cx, 0).top(), px(300.)); + cx.simulate_keystrokes("escape"); + cx.update(|window, cx| assert!(WindowState::read(window, cx).active_dialogs.is_empty())); + } + + #[gpui::test] + fn zero_theme_duration_displays_fade_entrances_immediately(cx: &mut TestAppContext) { + for entrance in [DialogEntrance::Fade, DialogEntrance::FadeSlide] { + let cx = window(cx, size(px(1000.), px(800.))); + cx.update(|_, cx| { + cx.set_reduce_motion(false); + crate::Theme::update(cx, |theme| theme.motion.duration_normal = Duration::ZERO); + }); + open(cx, move |dialog, _, _| { + dialog + .title("Immediate") + .child("body") + .entrance(entrance) + .margin_top(px(300.)) + }); + assert_eq!(surface(cx, 0).top(), px(300.)); + } + } + + #[gpui::test] + fn alert_dialog_uses_the_same_entrance(cx: &mut TestAppContext) { + for entrance in [ + DialogEntrance::SlideDown, + DialogEntrance::Fade, + DialogEntrance::FadeSlide, + DialogEntrance::None, + ] { + let cx = window(cx, size(px(1000.), px(800.))); + cx.update(|window, cx| { + let alert = crate::dialog::AlertDialog::new(cx) + .title("Alert") + .entrance(entrance); + assert_eq!(alert.build_surface(window, cx).props.entrance, entrance); + window.open_alert_dialog(cx, move |alert, _, _| { + alert.title("Alert").entrance(entrance) + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(surface(cx, 0).top(), px(80.)); + } + } } diff --git a/crates/component/src/dialog/entrance.rs b/crates/component/src/dialog/entrance.rs new file mode 100644 index 0000000000..99b34ff75a --- /dev/null +++ b/crates/component/src/dialog/entrance.rs @@ -0,0 +1,177 @@ +use gpui::{ + AnyElement, App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, Pixels, Window, point, px, +}; +use serde::{Deserialize, Serialize}; + +/// The entrance presentation shared by Dialog and AlertDialog. +/// +/// Closing remains immediate. Reduced motion displays the final state for every option. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum DialogEntrance { + /// Slide down from the window top while fading in, preserving the legacy entrance. + #[default] + SlideDown, + /// Fade in at the final position without movement. + Fade, + /// Fade in while settling from just above the final position. + /// + /// Travel comes from the theme's short motion distance and is limited by + /// the space above the resolved surface inside the window. + FadeSlide, + /// Show the surface and backdrop immediately without entrance animation. + None, +} + +/// Applies presentation travel after Positioner has resolved the resting bounds. +/// Paint, hit testing, and accessibility follow the same offset. +pub(super) struct EntranceSurface { + child: AnyElement, + travel: Pixels, + top_limit: Pixels, + pub(super) progress: f32, +} + +impl EntranceSurface { + pub(super) fn new(child: AnyElement, travel: Pixels, top_limit: Pixels) -> Self { + Self { + child, + travel, + top_limit, + progress: 0., + } + } +} + +impl IntoElement for EntranceSurface { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EntranceSurface { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + (self.child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let available = (bounds.top() - self.top_limit).max(px(0.)); + let travel = self.travel.max(px(0.)).min(available); + let offset = point(px(0.), -travel * (1. - self.progress.clamp(0., 1.))); + window.with_element_offset(offset, |window| self.child.prepaint(window, cx)); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ + Anchor, Context, InteractiveElement as _, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, TestAppContext, div, size, + }; + + struct SurfaceProbe { + progress: f32, + requested_y: Pixels, + clicks: usize, + } + + impl Render for SurfaceProbe { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let mut surface = EntranceSurface::new( + div() + .id("surface") + .w(px(200.)) + .h(px(300.)) + .debug_selector(|| "entrance-surface".into()) + .on_click(cx.listener(|probe, _, _, _| probe.clicks += 1)) + .into_any_element(), + px(8.), + px(16.), + ); + surface.progress = self.progress; + div().size_full().child( + gpui_base::Positioner::corner(Anchor::TopLeft, point(px(100.), self.requested_y)) + .margin(px(16.)) + .child(surface), + ) + } + } + + #[gpui::test] + fn fade_slide_uses_resolved_bounds_and_keeps_the_surface_inside_the_window( + cx: &mut TestAppContext, + ) { + let (view, cx) = cx.add_window_view(|_, _| SurfaceProbe { + progress: 0., + requested_y: px(200.), + clicks: 0, + }); + cx.simulate_resize(size(px(400.), px(400.))); + // Requested y=200 resolves to y=84 because the surface is 300px tall. + for (requested_y, positions) in [ + (px(200.), [px(76.), px(80.), px(84.)]), + (px(20.), [px(16.), px(18.), px(20.)]), + (px(0.), [px(16.), px(16.), px(16.)]), + ] { + for (progress, expected_y) in [0., 0.5, 1.].into_iter().zip(positions) { + cx.update(|window, cx| { + view.update(cx, |probe, cx| { + probe.progress = progress; + probe.requested_y = requested_y; + cx.notify(); + }); + window.draw(cx).clear(cx); + }); + let bounds = cx.debug_bounds("entrance-surface").unwrap(); + assert_eq!(bounds.top(), expected_y); + assert!(bounds.top() >= px(16.) && bounds.bottom() <= px(384.)); + // At the beginning of the clamped entrance this point is + // above the resting surface, so the hitbox must travel too. + cx.simulate_click(bounds.origin + point(px(10.), px(2.)), Default::default()); + } + } + cx.update(|_, cx| assert_eq!(view.read(cx).clicks, 9)); + } +} diff --git a/crates/component/src/dialog/mod.rs b/crates/component/src/dialog/mod.rs index 82626b27ac..44b66433bc 100644 --- a/crates/component/src/dialog/mod.rs +++ b/crates/component/src/dialog/mod.rs @@ -3,6 +3,7 @@ mod content; mod description; mod dialog; mod dispatch_anchor; +mod entrance; mod footer; mod header; mod title; @@ -12,6 +13,7 @@ pub use content::DialogContent; pub use description::DialogDescription; pub use dialog::*; pub(crate) use dispatch_anchor::DialogDispatchAnchor; +pub use entrance::DialogEntrance; pub use footer::*; pub use header::DialogHeader; pub use title::DialogTitle; diff --git a/crates/story/src/stories/dialog_story.rs b/crates/story/src/stories/dialog_story.rs index 847d8c5def..e2461ebbb9 100644 --- a/crates/story/src/stories/dialog_story.rs +++ b/crates/story/src/stories/dialog_story.rs @@ -6,8 +6,8 @@ use gpui_kit::component::{ button::{Button, ButtonVariants as _}, date_picker::{DatePicker, DatePickerState}, dialog::{ - Dialog, DialogAction, DialogClose, DialogDescription, DialogFooter, DialogHeader, - DialogTitle, + Dialog, DialogAction, DialogClose, DialogDescription, DialogEntrance, DialogFooter, + DialogHeader, DialogTitle, }, h_flex, input::{Input, InputState}, @@ -29,6 +29,12 @@ enum ToggleDialogOption { Keyboard, } +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = dialog_story, no_json)] +struct SetDialogEntrance { + entrance: DialogEntrance, +} + pub struct DialogStory { focus_handle: FocusHandle, selected_value: Option, @@ -41,6 +47,7 @@ pub struct DialogStory { close_button: bool, keyboard: bool, overlay_closable: bool, + entrance: DialogEntrance, } struct MyTable { @@ -140,6 +147,7 @@ impl DialogStory { close_button: true, keyboard: true, overlay_closable: true, + entrance: DialogEntrance::default(), table, } } @@ -170,6 +178,7 @@ impl DialogStory { .keyboard(self.keyboard) .close_button(self.close_button) .overlay_closable(overlay_closable) + .entrance(self.entrance) .on_ok({ let view = view.clone(); let input1 = input1.clone(); @@ -626,6 +635,10 @@ impl Render for DialogStory { } cx.notify(); })) + .on_action(cx.listener(|this, action: &SetDialogEntrance, _, cx| { + this.entrance = action.entrance; + cx.notify(); + })) .size_full() .child( v_flex() @@ -637,7 +650,8 @@ impl Render for DialogStory { let overlay_closable = self.overlay_closable; let close_button = self.close_button; let keyboard = self.keyboard; - move |menu, _, _| { + let entrance = self.entrance; + move |menu, window, cx| { menu.menu_with_check( "Overlay", overlay, @@ -658,6 +672,32 @@ impl Render for DialogStory { keyboard, Box::new(ToggleDialogOption::Keyboard), ) + .submenu( + "Entrance", + window, + cx, + move |menu, _, _| { + [ + ("Slide down", DialogEntrance::SlideDown), + ("Fade", DialogEntrance::Fade), + ("Fade and slide", DialogEntrance::FadeSlide), + ("No animation", DialogEntrance::None), + ] + .into_iter() + .fold( + menu, + |menu, (label, option)| { + menu.menu_with_check( + label, + entrance == option, + Box::new(SetDialogEntrance { + entrance: option, + }), + ) + }, + ) + }, + ) } }, )) diff --git a/website/component/alert-dialog.md b/website/component/alert-dialog.md index be6f035f17..ad1fdbf2b7 100644 --- a/website/component/alert-dialog.md +++ b/website/component/alert-dialog.md @@ -25,6 +25,13 @@ use gpui_kit::component::WindowExt; ## Usage +### Entrance animation + +Use `.entrance(DialogEntrance::Fade)`, `.entrance(DialogEntrance::FadeSlide)`, or +`.entrance(DialogEntrance::None)` to override the default `SlideDown` entrance. +AlertDialog follows the same theme timing, window boundaries, and reduced-motion +policy as [Dialog](./dialog.md#entrance-animation). Closing remains immediate. + ### Setup Application Root View Like Dialog, you need to set up your application's root view to render the dialog layer. See [Dialog documentation](./dialog.md#setup-application-root-view) for details. @@ -396,6 +403,7 @@ window.open_alert_dialog(cx, |alert, _, _| { | `overlay_closable(bool)` | Allow clicking overlay to close, default `false` | | `close_button(bool)` | Show/hide close button, default `false` | | `keyboard(bool)` | Support ESC key to close, default `true` | +| `entrance(DialogEntrance)` | Set entrance animation, default `SlideDown` | | `on_ok(callback)` | Set OK button callback, return `true` to close dialog | | `on_cancel(callback)` | Set cancel button callback, return `true` to close dialog | | `on_close(callback)` | Set callback after dialog closes | diff --git a/website/component/dialog.md b/website/component/dialog.md index a843af6f9d..ce44bd2ed3 100644 --- a/website/component/dialog.md +++ b/website/component/dialog.md @@ -111,6 +111,26 @@ window.open_dialog(cx, |dialog, _, _| { }) ``` +### Entrance animation + +`Dialog` and `AlertDialog` share `DialogEntrance`. The default `SlideDown` +preserves the existing slide from the window top. `Fade` fades at the final +position; `FadeSlide` fades while moving down by the theme's short motion +distance; `None` immediately displays both the surface and backdrop. + +```rust +use gpui_kit::component::dialog::DialogEntrance; + +window.open_dialog(cx, |dialog, _, _| { + dialog.title("Settings").entrance(DialogEntrance::FadeSlide) +}); +``` + +`Fade` and `FadeSlide` use the theme's normal duration and enter easing. +`FadeSlide` applies travel after resolving window boundaries and limits it to +available space above the surface. Reduced motion displays every entrance at +rest immediately. Closing remains immediate for every option. + ### Action Buttons A `Dialog` puts its own buttons in the [`footer`](#dialogfooter) and has them @@ -420,6 +440,7 @@ window.open_dialog(cx, |dialog, _, _| { | `w(px)` / `width(px)` | Set dialog width | | `max_w(px)` | Set maximum width | | `margin_top(px)` | Set top margin | +| `entrance(DialogEntrance)` | Set entrance animation (default: `SlideDown`) | | `overlay(bool)` | Show/hide overlay (default: true) | | `overlay_closable(bool)` | Allow closing by clicking overlay (default: true) | | `keyboard(bool)` | Allow closing with ESC key (default: true) | diff --git a/website/zh-CN/component/alert-dialog.md b/website/zh-CN/component/alert-dialog.md index ff232f9136..cdcadc7113 100644 --- a/website/zh-CN/component/alert-dialog.md +++ b/website/zh-CN/component/alert-dialog.md @@ -25,6 +25,10 @@ use gpui_kit::component::WindowExt; ## 用法 +### 入场动画 + +使用 `.entrance(DialogEntrance::Fade)`、`.entrance(DialogEntrance::FadeSlide)` 或 `.entrance(DialogEntrance::None)` 可以替换默认的 `SlideDown` 入场方式。AlertDialog 与 [Dialog](./dialog.md#入场动画) 共用主题时长、窗口边界处理和 reduced motion 策略。关闭仍然立即生效。 + ### 配置应用根视图 与 Dialog 一样,你需要在应用根视图中渲染 dialog layer。具体可参考 [Dialog 文档](./dialog.md#setup-application-root-view)。 @@ -390,6 +394,7 @@ window.open_alert_dialog(cx, |alert, _, _| { | `overlay_closable(bool)` | 是否允许点击遮罩关闭,默认 `false` | | `close_button(bool)` | 是否显示关闭按钮,默认 `false` | | `keyboard(bool)` | 是否支持 ESC 关闭,默认 `true` | +| `entrance(DialogEntrance)` | 设置入场动画,默认 `SlideDown` | | `on_ok(callback)` | 设置确认回调,返回 `true` 时关闭 | | `on_cancel(callback)` | 设置取消回调,返回 `true` 时关闭 | | `on_close(callback)` | 设置关闭后的回调 | diff --git a/website/zh-CN/component/dialog.md b/website/zh-CN/component/dialog.md index 8509c817e5..731c0e8372 100644 --- a/website/zh-CN/component/dialog.md +++ b/website/zh-CN/component/dialog.md @@ -109,6 +109,20 @@ window.open_dialog(cx, |dialog, _, _| { }) ``` +### 入场动画 + +`Dialog` 和 `AlertDialog` 共用 `DialogEntrance`。默认的 `SlideDown` 保留从窗口顶部滑入的效果;`Fade` 在最终位置淡入;`FadeSlide` 淡入时按主题定义的短移动距离向下移动;`None` 立即显示弹窗和遮罩。 + +```rust +use gpui_kit::component::dialog::DialogEntrance; + +window.open_dialog(cx, |dialog, _, _| { + dialog.title("设置").entrance(DialogEntrance::FadeSlide) +}); +``` + +`Fade` 和 `FadeSlide` 使用主题的普通动画时长及入场缓动。`FadeSlide` 在完成窗口边界定位后施加偏移,移动距离不会超过弹窗上方的可用空间。启用 reduced motion 时,所有方式都会立即显示最终状态。所有方式均保持立即关闭。 + ### 操作按钮 `Dialog` 自己的按钮放在 [`footer`](#dialogfooter) 里并派发 `Confirm` / `Cancel`;`on_ok`、`on_cancel` 决定 Enter 与 Esc 的行为。需要默认按钮的确认框请用 [AlertDialog](./alert-dialog.md)。