Skip to main content

frus_widgets/
sheet.rs

1//! [`DraggableScrollableSheet`]: a panel along the bottom that **follows the finger**
2//! between the heights it may rest at, and hands the gesture to the list inside it.
3//!
4//! ```ignore
5//! Stack::new().layer(body).layer(
6//!     DraggableScrollableSheet::new(places)
7//!         .min(0.25)
8//!         .snap_sizes([0.5])
9//!         .on_dismiss(Msg::CloseSheet),
10//! )
11//! ```
12//!
13//! The sheet fills the box it is given and draws nothing of its own: its panel sits along
14//! that box's bottom edge, as tall as the share of it the sheet is at, and the content
15//! decides what a sheet looks like.
16//!
17//! # Who owns the finger
18//!
19//! A sheet whose content scrolls is two surfaces under one finger, and neither can be
20//! chosen at the press. The shell splits **every movement** between them, which is what
21//! lets the gesture change hands without the finger lifting:
22//!
23//! - moving **up**, the sheet grows until it is as tall as it goes, and only what is left
24//!   over scrolls the list — unless the list is already scrolled, in which case all of it
25//!   does;
26//! - moving **down**, the list scrolls back to its top first, and only what is left over
27//!   lowers the sheet.
28//!
29//! On release the same question is asked of the velocity: a sheet between two stops, or
30//! thrown while its list is at the top, settles; a list thrown downwards while scrolled,
31//! or upwards under a sheet already at full height, flings.
32
33use std::cell::{OnceCell, RefCell};
34use std::collections::HashMap;
35
36use frus_core::{ClampingScrollSimulation, Curve, Point, Rect, Scene, Simulation, Tolerance};
37use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
38
39use crate::interaction::{Status, WidgetId};
40use crate::theme::Theme;
41use crate::widget::Widget;
42
43/// The slowest a sheet travels to the stop it is settling on, in px/s — the reference's
44/// figure. A release that barely moved would otherwise creep the last few pixels.
45pub const SHEET_SNAP_MIN_SPEED: f32 = 1600.0;
46
47/// What a [`DraggableScrollableSheet`] tells the frame about itself. Heights are shares
48/// of the box the sheet fills, from `0.0` to `1.0`.
49#[derive(Clone, Debug, PartialEq)]
50pub struct SheetSpec {
51    /// The lowest height it rests at while it stays open.
52    pub min: f32,
53    /// The highest.
54    pub max: f32,
55    /// Where it starts, before any finger has moved it.
56    pub initial: f32,
57    /// Whether a release **settles on a stop**, rather than coasting to wherever its
58    /// momentum runs out.
59    pub snap: bool,
60    /// Every height it may settle on when it snaps, in increasing order: `min`, the
61    /// sizes in between, `max` — and `0.0` below them when it can be dismissed.
62    pub stops: Vec<f32>,
63    /// Whether it may be lowered past `min` to nothing, which closes it.
64    pub dismissible: bool,
65}
66
67impl SheetSpec {
68    /// The lowest it can be moved to: nothing when it can be dismissed, `min` otherwise.
69    pub fn floor(&self) -> f32 {
70        if self.dismissible {
71            0.0
72        } else {
73            self.min.min(self.max)
74        }
75    }
76
77    /// `size`, kept between the floor and `max`.
78    pub fn clamp(&self, size: f32) -> f32 {
79        size.clamp(self.floor(), self.max)
80    }
81}
82
83/// One sheet of the frame: where its panel is, what it was built with, and the scroll
84/// areas walked inside it.
85#[derive(Clone, Debug, PartialEq)]
86pub struct SheetArea {
87    /// The panel's identity, and the key of its retained state.
88    pub id: WidgetId,
89    /// The panel, on screen.
90    pub panel: Rect,
91    /// The height the sheet's shares are shares **of**, in px.
92    pub available: f32,
93    /// Its configuration this frame.
94    pub spec: SheetSpec,
95    /// The scroll areas registered inside the panel. Only these hand a gesture to the
96    /// sheet: an area elsewhere on the screen owns its own finger.
97    pub areas: Vec<WidgetId>,
98}
99
100impl SheetArea {
101    /// Whether `point` is on the panel — never, while it has no height.
102    pub fn contains(&self, point: Point) -> bool {
103        let p = self.panel;
104        p.height > 0.0
105            && point.x >= p.x
106            && point.x < p.x + p.width
107            && point.y >= p.y
108            && point.y < p.y + p.height
109    }
110}
111
112/// Where an application asks a sheet to go — the reference's `DraggableScrollableController`,
113/// as a request rather than an object.
114///
115/// Sent with `Command::sheet(key, to)` to the sheet wrapped in `keyed(key, …)`. A height is a
116/// share of the sheet's box, kept between its floor and `max` as a finger's would be — so
117/// asking a sheet that can be dismissed for nothing puts it away, message and all.
118///
119/// ```ignore
120/// Command::sheet("places", SheetTo::size(1.0).animate(0.3, Curve::ease()))
121/// ```
122///
123/// What it does to a sheet is what the reference's controller does:
124///
125/// - **whatever was carrying it stops** — a settle, a coast, an earlier request;
126/// - **it does not snap afterwards**, even on a sheet that snaps: snapping answers a finger;
127/// - **a finger on the sheet refuses it**, the rule a scroll request keeps too — and a finger
128///   landing on a sheet a request is moving stops it where it is.
129#[derive(Clone, Debug, PartialEq)]
130pub struct SheetTo {
131    target: SheetTarget,
132    /// Over how many seconds, along what curve. `None` arrives at once.
133    animation: Option<(f32, Curve)>,
134}
135
136/// The height a [`SheetTo`] names.
137#[derive(Clone, Copy, Debug, PartialEq)]
138enum SheetTarget {
139    Size(f32),
140    Initial,
141}
142
143impl SheetTo {
144    /// To `size`, a share of the box, at once — the reference's `jumpTo`.
145    pub fn size(size: f32) -> Self {
146        Self {
147            target: SheetTarget::Size(size),
148            animation: None,
149        }
150    }
151
152    /// Back to the height it started at, at once — the reference's `reset`.
153    pub fn initial() -> Self {
154        Self {
155            target: SheetTarget::Initial,
156            animation: None,
157        }
158    }
159
160    /// Over `duration` seconds along `curve` rather than at once — the reference's
161    /// `animateTo`. A duration of nought or less is no animation.
162    #[must_use]
163    pub fn animate(mut self, duration: f32, curve: Curve) -> Self {
164        self.animation = (duration > 0.0).then_some((duration, curve));
165        self
166    }
167}
168
169/// How one movement of the finger is shared out, in px: `grow` is how far it went
170/// **up** (negative for down), `list_offset` how far the list inside is scrolled, `size`
171/// how tall the sheet is, and `floor` and `max` how low and high it may go.
172///
173/// Returns `(sheet, list)`: how far the sheet grows, and how far the list's offset
174/// grows. They always add up to `grow`, so no movement of the finger is lost — what the
175/// sheet cannot take past its floor goes to the list, whose own physics refuses it at
176/// its edge and says so.
177pub fn split_sheet_drag(
178    grow: f32,
179    list_offset: f32,
180    size: f32,
181    floor: f32,
182    max: f32,
183) -> (f32, f32) {
184    if grow > 0.0 {
185        // A list already scrolled keeps the finger: the sheet grows only from the top.
186        if list_offset > 0.0 {
187            return (0.0, grow);
188        }
189        let sheet = grow.min((max - size).max(0.0));
190        (sheet, grow - sheet)
191    } else if grow < 0.0 {
192        let down = -grow;
193        let list_back = down.min(list_offset.max(0.0));
194        let rest = down - list_back;
195        let sheet = rest.min((size - floor).max(0.0));
196        (-sheet, -(list_back + rest - sheet))
197    } else {
198        (0.0, 0.0)
199    }
200}
201
202/// Whether a release at `velocity` px/s — positive growing the sheet — is the **sheet's**
203/// to settle, or the list's to fling. The reference's rule.
204///
205/// The list keeps it when the finger was still over a sheet already at rest on a stop,
206/// when it was thrown down while scrolled, or thrown up under a sheet already at full
207/// height. Everything else settles the sheet.
208pub fn sheet_takes_release(
209    velocity: f32,
210    list_offset: f32,
211    size: f32,
212    spec: &SheetSpec,
213    available: f32,
214) -> bool {
215    let tolerance = Tolerance::PIXELS;
216    let still = velocity.abs() <= tolerance.velocity;
217    let px = size * available;
218    let on_stop = !spec.snap
219        || spec
220            .stops
221            .iter()
222            .any(|stop| (stop * available - px).abs() <= tolerance.distance);
223    let at_max = px >= spec.max * available - tolerance.distance;
224    !((still && on_stop) || (velocity < 0.0 && list_offset > 0.0) || (velocity > 0.0 && at_max))
225}
226
227/// The motion of a sheet to the stop it is settling on: a **constant speed** — the
228/// release's, or [`SHEET_SNAP_MIN_SPEED`] if that is slower — that stops dead on the
229/// stop. The reference's own, and deliberately not a spring: a sheet that overshot its
230/// stop would uncover and cover again whatever is behind it.
231#[derive(Clone, Copy, Debug, PartialEq)]
232pub struct SnapSimulation {
233    start: f32,
234    target: f32,
235    velocity: f32,
236}
237
238impl SnapSimulation {
239    /// From `position` towards `target`, released at `velocity`. The direction is the
240    /// target's, not the velocity's: a slow release can settle against its own motion.
241    pub fn new(position: f32, velocity: f32, target: f32) -> Self {
242        let velocity = if target < position {
243            velocity.min(-SHEET_SNAP_MIN_SPEED)
244        } else {
245            velocity.max(SHEET_SNAP_MIN_SPEED)
246        };
247        Self {
248            start: position,
249            target,
250            velocity,
251        }
252    }
253
254    /// Where it is heading.
255    pub fn target(&self) -> f32 {
256        self.target
257    }
258
259    /// The speed it travels at, in px/s — the same from start to stop.
260    pub fn velocity(&self) -> f32 {
261        self.velocity
262    }
263}
264
265impl Simulation for SnapSimulation {
266    fn x(&self, time: f32) -> f32 {
267        let position = self.start + self.velocity * time;
268        if (self.velocity >= 0.0 && position > self.target)
269            || (self.velocity < 0.0 && position < self.target)
270        {
271            self.target
272        } else {
273            position
274        }
275    }
276
277    fn dx(&self, time: f32) -> f32 {
278        if self.is_done(time) {
279            0.0
280        } else {
281            self.velocity
282        }
283    }
284
285    fn is_done(&self, time: f32) -> bool {
286        self.x(time) == self.target
287    }
288}
289
290/// What carries a released sheet.
291#[derive(Clone, Copy, Debug)]
292enum Motion {
293    /// To a stop.
294    Snap(SnapSimulation),
295    /// Wherever the momentum runs out, as a list would.
296    Coast(ClampingScrollSimulation),
297}
298
299/// A motion under way, in px, with what it was started against.
300#[derive(Clone, Copy, Debug)]
301struct Settle {
302    motion: Motion,
303    elapsed: f32,
304    available: f32,
305    floor: f32,
306    max: f32,
307    /// The list the finger was on when it let go, which is handed what is left of a throw
308    /// that reaches full height.
309    list: Option<WidgetId>,
310}
311
312/// A height an application asked for, on its way there. In shares of the box, since a
313/// request says nothing about pixels and the box may change under it.
314#[derive(Clone, Debug)]
315struct Drive {
316    from: f32,
317    to: f32,
318    duration: f32,
319    curve: Curve,
320    elapsed: f32,
321}
322
323/// The retained height of one sheet, and whatever is moving it.
324#[derive(Clone, Debug)]
325pub struct SheetState {
326    size: f32,
327    held: bool,
328    settle: Option<Settle>,
329    /// A request under way — never at the same time as a settle.
330    drive: Option<Drive>,
331    dismissed: bool,
332    available: f32,
333    /// A throw that arrived at full height this frame: the list it goes on in, and the
334    /// velocity it goes on at, in px/s. Taken by whoever steps the sheets.
335    handover: Option<(WidgetId, f32)>,
336}
337
338impl SheetState {
339    /// A sheet nothing has moved yet: at its initial height.
340    pub fn new(spec: &SheetSpec) -> Self {
341        Self {
342            size: spec.clamp(spec.initial),
343            held: false,
344            settle: None,
345            drive: None,
346            dismissed: false,
347            available: 0.0,
348            handover: None,
349        }
350    }
351
352    /// Its height, as a share of the box.
353    pub fn size(&self) -> f32 {
354        self.size
355    }
356
357    /// `true` while a release, or an application's request, is still carrying it.
358    pub fn is_settling(&self) -> bool {
359        self.settle.is_some() || self.drive.is_some()
360    }
361
362    /// The height its shares were last taken of, in px — kept so that a panel lowered
363    /// to nothing under the finger can still be measured, and brought back.
364    pub fn available(&self) -> f32 {
365        self.available
366    }
367
368    /// A finger is on it: whatever was carrying it — a release or a request — stops where
369    /// it is.
370    fn hold(&mut self) {
371        self.held = true;
372        self.settle = None;
373        self.drive = None;
374    }
375
376    /// Moves it by `delta`, a share of the box, within its floor and `max`.
377    fn drag(&mut self, spec: &SheetSpec, delta: f32, available: f32) {
378        self.hold();
379        if available > 0.0 {
380            self.available = available;
381        }
382        self.size = spec.clamp(self.size + delta);
383        if self.size > 0.0 {
384            self.dismissed = false;
385        }
386    }
387
388    /// The finger lets go at `velocity` px/s, positive growing it, over a box
389    /// `available` px tall — from `list`, when it was on one, which a throw that carries
390    /// the sheet to full height goes on into.
391    fn release(&mut self, spec: &SheetSpec, available: f32, velocity: f32, list: Option<WidgetId>) {
392        self.held = false;
393        self.settle = None;
394        self.drive = None;
395        if available <= 0.0 {
396            return;
397        }
398        self.available = available;
399        let tolerance = Tolerance::PIXELS;
400        let position = self.size * available;
401        let motion = if spec.snap {
402            let stops: Vec<f32> = spec.stops.iter().map(|stop| stop * available).collect();
403            let target =
404                crate::physics::snap_target(position, velocity, &stops, tolerance.velocity);
405            Motion::Snap(SnapSimulation::new(position, velocity, target))
406        } else if velocity.abs() > tolerance.velocity {
407            Motion::Coast(ClampingScrollSimulation::new(position, velocity, tolerance))
408        } else {
409            return;
410        };
411        self.settle = Some(Settle {
412            motion,
413            elapsed: 0.0,
414            available,
415            floor: spec.floor() * available,
416            max: spec.max * available,
417            // Only a throw **up** that starts **short of** full height can arrive there
418            // with anything to hand over. A still finger let go of a sheet already at the
419            // top still gets a snap, at the snap's own speed — and handing that to the list
420            // would fling a list nobody threw.
421            list: list
422                .filter(|_| velocity > 0.0 && position < spec.max * available - tolerance.distance),
423        });
424    }
425
426    /// An application asks for `to`. `false` when a finger is on it, which refuses it.
427    fn go(&mut self, spec: &SheetSpec, to: &SheetTo) -> bool {
428        if self.held {
429            return false;
430        }
431        self.settle = None;
432        self.drive = None;
433        let target = spec.clamp(match to.target {
434            SheetTarget::Size(size) => size,
435            SheetTarget::Initial => spec.initial,
436        });
437        match &to.animation {
438            Some((duration, curve)) if target != self.size => {
439                self.drive = Some(Drive {
440                    from: self.size,
441                    to: target,
442                    duration: *duration,
443                    curve: curve.clone(),
444                    elapsed: 0.0,
445                });
446            }
447            _ => self.size = target,
448        }
449        // Asked back up from nothing, it is open again — and may be put away again.
450        if target > 0.0 {
451            self.dismissed = false;
452        }
453        true
454    }
455
456    /// Advances by `dt` seconds. Returns `(still moving, dismissed on this frame)`.
457    fn advance(&mut self, spec: &SheetSpec, dt: f32) -> (bool, bool) {
458        if self.held {
459            return (false, false);
460        }
461        let mut moving = false;
462        // A request goes where it was told and stops: no snap afterwards, since snapping
463        // answers a finger, and none of a throw's hand-over, since nobody threw anything.
464        if let Some(drive) = &mut self.drive {
465            drive.elapsed += dt;
466            let t = (drive.elapsed / drive.duration).min(1.0);
467            self.size = spec.clamp(drive.from + (drive.to - drive.from) * drive.curve.transform(t));
468            if t >= 1.0 {
469                self.size = drive.to;
470                self.drive = None;
471            } else {
472                moving = true;
473            }
474        }
475        if let Some(settle) = &mut self.settle {
476            settle.elapsed += dt;
477            // The velocity it is travelling up at, in px/s, when it is at full height —
478            // `None` otherwise.
479            let at_max = |x: f32| x >= settle.max - Tolerance::PIXELS.distance;
480            let (x, done, rising) = match settle.motion {
481                Motion::Snap(snap) => {
482                    let x = snap.x(settle.elapsed);
483                    let done = snap.is_done(settle.elapsed);
484                    (x, done, (done && at_max(x)).then_some(snap.velocity()))
485                }
486                Motion::Coast(coast) => {
487                    let x = coast.x(settle.elapsed);
488                    // A coast stops at the sheet's own ends, not the list's.
489                    let out = x <= settle.floor || x >= settle.max;
490                    let done = out || coast.is_done(settle.elapsed);
491                    (x, done, at_max(x).then(|| coast.dx(settle.elapsed)))
492                }
493            };
494            self.size = x.clamp(settle.floor, settle.max) / settle.available;
495            // A throw that reaches full height does not stop there: what is left of it goes
496            // on into the list, or the list would seem to have nothing more to show. The
497            // reference's rule, tolerance and all — a snap arrives at its constant speed,
498            // so a sheet flicked to its top carries the list on at that speed.
499            if let (Some(velocity), Some(list)) = (rising.filter(|v| *v > 0.0), settle.list) {
500                self.handover = Some((list, velocity + Tolerance::PIXELS.velocity));
501                self.settle = None;
502                return (false, false);
503            }
504            if done {
505                self.settle = None;
506            } else {
507                moving = true;
508            }
509        }
510        if self.settle.is_none()
511            && self.drive.is_none()
512            && spec.dismissible
513            && !self.dismissed
514            && self.size <= 1e-4
515        {
516            self.size = 0.0;
517            self.dismissed = true;
518            return (moving, true);
519        }
520        (moving, false)
521    }
522}
523
524/// The height of the sheet `id`, as a share of its box — its initial one until anything
525/// has moved it.
526pub(crate) fn size_of(
527    states: &HashMap<WidgetId, SheetState>,
528    id: WidgetId,
529    spec: &SheetSpec,
530) -> f32 {
531    states
532        .get(&id)
533        .map(|state| state.size)
534        .unwrap_or_else(|| spec.clamp(spec.initial))
535}
536
537/// Moves the sheet `id` by `delta`, a share of its box, creating its state on the first
538/// move.
539pub(crate) fn drag_into(
540    states: &mut HashMap<WidgetId, SheetState>,
541    id: WidgetId,
542    spec: &SheetSpec,
543    delta: f32,
544    available: f32,
545) {
546    states
547        .entry(id)
548        .or_insert_with(|| SheetState::new(spec))
549        .drag(spec, delta, available);
550}
551
552/// A finger lands on the sheet `id`: a settle in progress stops under it.
553pub(crate) fn hold_of(states: &mut HashMap<WidgetId, SheetState>, id: WidgetId) {
554    if let Some(state) = states.get_mut(&id) {
555        state.hold();
556    }
557}
558
559/// The finger lets go of the sheet `id` — from `list`, when it was on one.
560pub(crate) fn release_of(
561    states: &mut HashMap<WidgetId, SheetState>,
562    id: WidgetId,
563    spec: &SheetSpec,
564    available: f32,
565    velocity: f32,
566    list: Option<WidgetId>,
567) {
568    if let Some(state) = states.get_mut(&id) {
569        state.release(spec, available, velocity, list);
570    }
571}
572
573/// An application asks the sheet `id` for `to`, creating its state if nothing has moved it
574/// yet. `false` when a finger is on it.
575pub(crate) fn go_to(
576    states: &mut HashMap<WidgetId, SheetState>,
577    id: WidgetId,
578    spec: &SheetSpec,
579    to: &SheetTo,
580) -> bool {
581    states
582        .entry(id)
583        .or_insert_with(|| SheetState::new(spec))
584        .go(spec, to)
585}
586
587/// What stepping the sheets of a frame produced.
588pub(crate) struct SheetsStep {
589    /// Whether any is still moving.
590    pub moving: bool,
591    /// The sheets lowered to nothing on this frame.
592    pub dismissed: Vec<WidgetId>,
593    /// The throws that reached full height on this frame: each list, and the velocity it
594    /// goes on at, in px/s, growing its offset.
595    pub handovers: Vec<(WidgetId, f32)>,
596}
597
598/// Advances every sheet of the frame, dropping the state of any that has left it — so a
599/// sheet shown again starts from its initial height.
600pub(crate) fn advance_all(
601    states: &mut HashMap<WidgetId, SheetState>,
602    areas: &[SheetArea],
603    dt: f32,
604) -> SheetsStep {
605    let mut step = SheetsStep {
606        moving: false,
607        dismissed: Vec::new(),
608        handovers: Vec::new(),
609    };
610    states.retain(|id, state| {
611        let Some(area) = areas.iter().find(|area| area.id == *id) else {
612            return false;
613        };
614        let (still, gone) = state.advance(&area.spec, dt);
615        step.moving |= still;
616        if gone {
617            step.dismissed.push(*id);
618        }
619        step.handovers.extend(state.handover.take());
620        true
621    });
622    step
623}
624
625/// A sheet along the bottom of the box it fills, dragged between the heights it may rest
626/// at — the reference's `DraggableScrollableSheet`.
627///
628/// Heights are shares of that box. It starts at [`initial`](Self::initial) (half),
629/// moves between [`min`](Self::min) (a quarter) and [`max`](Self::max) (all of it), and
630/// coasts where it is thrown unless it is told to [`snap`](Self::snap) — which giving it
631/// [`snap_sizes`](Self::snap_sizes) does. Given [`on_dismiss`](Self::on_dismiss), it can
632/// be lowered past `min` to nothing, and the message goes once it has arrived there.
633///
634/// The retained height is keyed by identity, and forgotten on the first frame the sheet
635/// is not in the tree: an application that removes a dismissed sheet and shows it again
636/// gets it back at its initial height.
637pub struct DraggableScrollableSheet<Msg = crate::callback::Callback> {
638    spec: SheetSpec,
639    sizes: Vec<f32>,
640    on_dismiss: Option<Msg>,
641    child: RefCell<Option<Box<dyn Widget<Msg>>>>,
642    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
643}
644
645impl<Msg: Clone + 'static> DraggableScrollableSheet<Msg> {
646    /// A sheet holding `child`, which is laid out as tall as the sheet is.
647    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
648        let mut sheet = Self {
649            spec: SheetSpec {
650                min: 0.25,
651                max: 1.0,
652                initial: 0.5,
653                snap: false,
654                stops: Vec::new(),
655                dismissible: false,
656            },
657            sizes: Vec::new(),
658            on_dismiss: None,
659            child: RefCell::new(Some(Box::new(child))),
660            built: OnceCell::new(),
661        };
662        sheet.restop();
663        sheet
664    }
665
666    /// The height it starts at, before a finger has moved it. Half, unset.
667    #[must_use]
668    pub fn initial(mut self, size: f32) -> Self {
669        self.spec.initial = size.clamp(0.0, 1.0);
670        self
671    }
672
673    /// The lowest height it rests at while it stays open. A quarter, unset.
674    #[must_use]
675    pub fn min(mut self, size: f32) -> Self {
676        self.spec.min = size.clamp(0.0, 1.0);
677        self.restop();
678        self
679    }
680
681    /// The highest height it goes to. All of the box, unset.
682    #[must_use]
683    pub fn max(mut self, size: f32) -> Self {
684        self.spec.max = size.clamp(0.0, 1.0);
685        self.restop();
686        self
687    }
688
689    /// Whether a release settles on a stop — `min`, `max` and any
690    /// [`snap_sizes`](Self::snap_sizes) — rather than coasting.
691    #[must_use]
692    pub fn snap(mut self, snap: bool) -> Self {
693        self.spec.snap = snap;
694        self
695    }
696
697    /// Heights between `min` and `max` it may also settle on, and snapping turned on.
698    #[must_use]
699    pub fn snap_sizes(mut self, sizes: impl IntoIterator<Item = f32>) -> Self {
700        self.sizes = sizes.into_iter().collect();
701        self.spec.snap = true;
702        self.restop();
703        self
704    }
705
706    /// The message sent once the sheet has been lowered to nothing — which this also
707    /// allows, below `min`. Unset, `min` is as low as it goes.
708    #[must_use]
709    pub fn on_dismiss(mut self, message: impl Into<Msg>) -> Self {
710        self.on_dismiss = Some(message.into());
711        self.spec.dismissible = true;
712        self.restop();
713        self
714    }
715
716    fn restop(&mut self) {
717        let (min, max) = (self.spec.min.min(self.spec.max), self.spec.max);
718        let mut stops = Vec::with_capacity(self.sizes.len() + 3);
719        if self.spec.dismissible {
720            stops.push(0.0);
721        }
722        stops.push(min);
723        stops.extend(self.sizes.iter().copied().filter(|s| *s > min && *s < max));
724        stops.push(max);
725        stops.sort_by(f32::total_cmp);
726        stops.dedup();
727        self.spec.stops = stops;
728    }
729}
730
731impl<Msg: Clone + 'static> Widget<Msg> for DraggableScrollableSheet<Msg> {
732    /// The whole box, with the panel along its bottom edge.
733    fn style(&self) -> Style {
734        Style {
735            width: Dimension::Percent(1.0),
736            height: Dimension::Percent(1.0),
737            flex_direction: FlexDirection::Column,
738            justify: Justify::End,
739            ..Default::default()
740        }
741    }
742
743    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
744        self.built.get_or_init(|| {
745            let child = self.child.borrow_mut().take();
746            vec![Box::new(SheetPanel {
747                spec: self.spec.clone(),
748                on_dismiss: self.on_dismiss.clone(),
749                children: child.into_iter().collect(),
750            })]
751        })
752    }
753
754    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
755
756    fn on_click(&self) -> Option<Msg> {
757        None
758    }
759
760    fn debug_name(&self) -> &'static str {
761        "DraggableScrollableSheet"
762    }
763}
764
765/// The part that moves: as wide as the sheet, as tall as the runtime says, with the
766/// content stretched over it.
767struct SheetPanel<Msg> {
768    spec: SheetSpec,
769    on_dismiss: Option<Msg>,
770    children: Vec<Box<dyn Widget<Msg>>>,
771}
772
773impl<Msg: Clone> Widget<Msg> for SheetPanel<Msg> {
774    /// The height here is the resting one; the layout replaces it with the retained one
775    /// (see `effective_style`).
776    fn style(&self) -> Style {
777        Style {
778            width: Dimension::Percent(1.0),
779            height: Dimension::Percent(self.spec.clamp(self.spec.initial)),
780            overlap: true,
781            align: Align::Stretch,
782            ..Default::default()
783        }
784    }
785
786    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
787        &self.children
788    }
789
790    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
791
792    fn on_click(&self) -> Option<Msg> {
793        None
794    }
795
796    fn sheet(&self) -> Option<&SheetSpec> {
797        Some(&self.spec)
798    }
799
800    fn on_sheet_dismissed(&self) -> Option<Msg> {
801        self.on_dismiss.clone()
802    }
803
804    fn debug_name(&self) -> &'static str {
805        "SheetPanel"
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812    use crate::{build_ui, Container, Runtime};
813    use frus_core::{Color, Primitive, Size};
814
815    fn spec(snap: bool, dismissible: bool) -> SheetSpec {
816        let mut sheet = DraggableScrollableSheet::<()>::new(Container::new()).min(0.25);
817        if snap {
818            sheet = sheet.snap_sizes([0.5]);
819        }
820        if dismissible {
821            sheet = sheet.on_dismiss(());
822        }
823        sheet.spec
824    }
825
826    /// **Up, the sheet first; then the list** — and a list already scrolled keeps the
827    /// finger, since a sheet grows only from the top of what it holds.
828    #[test]
829    fn up_grows_the_sheet_to_its_max_then_scrolls_the_list() {
830        assert_eq!(
831            split_sheet_drag(30.0, 0.0, 400.0, 200.0, 800.0),
832            (30.0, 0.0)
833        );
834        assert_eq!(
835            split_sheet_drag(30.0, 0.0, 790.0, 200.0, 800.0),
836            (10.0, 20.0),
837            "the handover inside one movement"
838        );
839        assert_eq!(
840            split_sheet_drag(30.0, 0.0, 800.0, 200.0, 800.0),
841            (0.0, 30.0)
842        );
843        assert_eq!(
844            split_sheet_drag(30.0, 5.0, 400.0, 200.0, 800.0),
845            (0.0, 30.0),
846            "a scrolled list keeps it"
847        );
848    }
849
850    /// **Down, the list back to its top first; then the sheet** — and what the sheet
851    /// cannot take below its floor still goes somewhere: to the list, which refuses it at
852    /// its edge.
853    #[test]
854    fn down_scrolls_the_list_home_then_lowers_the_sheet() {
855        assert_eq!(
856            split_sheet_drag(-30.0, 100.0, 400.0, 200.0, 800.0),
857            (0.0, -30.0)
858        );
859        assert_eq!(
860            split_sheet_drag(-30.0, 10.0, 400.0, 200.0, 800.0),
861            (-20.0, -10.0),
862            "the handover inside one movement"
863        );
864        assert_eq!(
865            split_sheet_drag(-30.0, 0.0, 400.0, 200.0, 800.0),
866            (-30.0, 0.0)
867        );
868        assert_eq!(
869            split_sheet_drag(-30.0, 0.0, 210.0, 200.0, 800.0),
870            (-10.0, -20.0),
871            "past the floor, the rest to the list"
872        );
873        assert_eq!(split_sheet_drag(0.0, 10.0, 400.0, 200.0, 800.0), (0.0, 0.0));
874    }
875
876    /// **Who settles a release**, case by case, as the reference decides it.
877    #[test]
878    fn a_release_goes_to_the_sheet_unless_the_list_was_thrown() {
879        let snapping = spec(true, false);
880        // At rest on a stop, a still finger moves nothing.
881        assert!(!sheet_takes_release(0.0, 0.0, 0.5, &snapping, 800.0));
882        // Between stops, even a still finger settles it.
883        assert!(sheet_takes_release(0.0, 0.0, 0.4, &snapping, 800.0));
884        // Thrown down while scrolled: the list's.
885        assert!(!sheet_takes_release(-900.0, 40.0, 1.0, &snapping, 800.0));
886        // Thrown down with the list at its top: the sheet's.
887        assert!(sheet_takes_release(-900.0, 0.0, 1.0, &snapping, 800.0));
888        // Thrown up under a full sheet: the list's; under a half one, the sheet's.
889        assert!(!sheet_takes_release(900.0, 0.0, 1.0, &snapping, 800.0));
890        assert!(sheet_takes_release(900.0, 0.0, 0.5, &snapping, 800.0));
891        // A coasting sheet has no stops to be off, so a still finger never moves it.
892        assert!(!sheet_takes_release(
893            0.0,
894            0.0,
895            0.4,
896            &spec(false, false),
897            800.0
898        ));
899    }
900
901    /// **The snap is a constant speed, never slower than the floor, and it stops dead on
902    /// the stop** — heading for the stop even against a slow release's own motion.
903    #[test]
904    fn a_snap_travels_at_least_the_minimum_speed_and_stops_on_the_stop() {
905        let up = SnapSimulation::new(300.0, 100.0, 400.0);
906        assert_eq!(up.dx(0.0), SHEET_SNAP_MIN_SPEED);
907        assert!((up.x(0.05) - 380.0).abs() < 1e-3);
908        // 300 + 1600 × 0.09 = 444: the first frame past the stop is on it, not beyond.
909        assert_eq!(
910            up.x(0.09),
911            400.0,
912            "the frame it passes the stop, on the stop"
913        );
914        assert_eq!(up.x(1.0), 400.0, "never past it");
915        assert!(up.is_done(1.0) && up.dx(1.0) == 0.0);
916        let fast = SnapSimulation::new(300.0, 4000.0, 400.0);
917        assert_eq!(fast.dx(0.0), 4000.0, "a faster release keeps its speed");
918        let against = SnapSimulation::new(300.0, 50.0, 200.0);
919        assert_eq!(against.dx(0.0), -SHEET_SNAP_MIN_SPEED, "towards the stop");
920        assert_eq!(against.x(1.0), 200.0);
921    }
922
923    /// **A request moves a sheet at once**, kept between its floor and `max` as a finger's
924    /// height is — and `initial` puts it back where it started (milestone 523).
925    #[test]
926    fn a_request_moves_a_sheet_at_once_within_its_bounds() {
927        let snapping = spec(true, false);
928        let mut state = SheetState::new(&snapping);
929        assert!(state.go(&snapping, &SheetTo::size(0.9)));
930        assert_eq!(state.size(), 0.9);
931        assert!(!state.is_settling(), "at once is at once");
932        state.go(&snapping, &SheetTo::size(3.0));
933        assert_eq!(state.size(), 1.0, "no higher than max");
934        state.go(&snapping, &SheetTo::size(0.0));
935        assert_eq!(
936            state.size(),
937            0.25,
938            "no lower than min, on a sheet that stays open"
939        );
940        state.go(&snapping, &SheetTo::initial());
941        assert_eq!(state.size(), 0.5, "and back where it started");
942    }
943
944    /// **An animated request follows its curve, and stays where it arrives** — the
945    /// reference's `animateTo`, which does not snap afterwards: snapping answers a finger.
946    #[test]
947    fn an_animated_request_follows_its_curve_and_stays_where_it_arrives() {
948        let snapping = spec(true, false);
949        let mut state = SheetState::new(&snapping);
950        state.go(&snapping, &SheetTo::size(0.8).animate(0.3, Curve::Linear));
951        assert_eq!(state.size(), 0.5, "nothing moves before a frame");
952        let (moving, _) = state.advance(&snapping, 0.15);
953        assert!(moving);
954        assert!(
955            (state.size() - 0.65).abs() < 1e-4,
956            "half-way along a straight line: {}",
957            state.size()
958        );
959        state.advance(&snapping, 0.2);
960        assert_eq!(state.size(), 0.8);
961        assert!(!state.is_settling());
962        // 0.8 is no stop of this sheet — its stops are a quarter, half and all — and a
963        // request does not look for one.
964        assert!(!settle(&mut state, &snapping));
965        assert_eq!(state.size(), 0.8);
966        // Along another curve it is somewhere else half-way: the curve is the one asked for.
967        let mut eased = SheetState::new(&snapping);
968        eased.go(
969            &snapping,
970            &SheetTo::size(0.8).animate(0.3, Curve::Decelerate),
971        );
972        eased.advance(&snapping, 0.15);
973        assert!(
974            (eased.size() - (0.5 + 0.3 * 0.75)).abs() < 1e-4,
975            "{}",
976            eased.size()
977        );
978        // And no duration is no animation.
979        let mut instant = SheetState::new(&snapping);
980        instant.go(&snapping, &SheetTo::size(0.8).animate(0.0, Curve::Linear));
981        assert_eq!(instant.size(), 0.8);
982    }
983
984    /// **A finger outranks a request**: one on the sheet refuses it, and one that lands on a
985    /// sheet a request is moving stops it where it is. A request, for its part, takes over
986    /// from a settle.
987    #[test]
988    fn a_finger_refuses_a_request_and_stops_one_under_way() {
989        let snapping = spec(true, false);
990        let mut state = SheetState::new(&snapping);
991        state.drag(&snapping, 0.1, 800.0);
992        assert!(
993            !state.go(&snapping, &SheetTo::size(1.0)),
994            "refused under a finger"
995        );
996        assert!((state.size() - 0.6).abs() < 1e-6);
997        state.release(&snapping, 800.0, 0.0, None);
998        assert!(state.is_settling(), "let go off a stop, it settles");
999        assert!(state.go(&snapping, &SheetTo::size(0.9).animate(0.5, Curve::Linear)));
1000        state.advance(&snapping, 0.25);
1001        let midway = state.size();
1002        assert!(
1003            (midway - 0.75).abs() < 1e-4,
1004            "the request's way, not the settle's: {midway}"
1005        );
1006        state.hold();
1007        state.advance(&snapping, 0.25);
1008        assert_eq!(state.size(), midway, "a finger stopped it where it was");
1009        assert!(!state.is_settling());
1010    }
1011
1012    /// **Asked for nothing, a sheet that can be dismissed is put away** — once it has
1013    /// arrived there, as a finger would have it — and asked back, it opens and may be put
1014    /// away again.
1015    #[test]
1016    fn a_request_to_nothing_puts_a_dismissible_sheet_away() {
1017        let closing = spec(true, true);
1018        let mut state = SheetState::new(&closing);
1019        // A curve that is there by half-way: the sheet is at nothing while the request is
1020        // still under way, and it is not put away until the request is done.
1021        let early = Curve::Interval {
1022            begin: 0.0,
1023            end: 0.5,
1024            inner: Box::new(Curve::Linear),
1025        };
1026        state.go(&closing, &SheetTo::size(0.0).animate(0.2, early));
1027        let (_, gone) = state.advance(&closing, 0.15);
1028        assert_eq!(state.size(), 0.0);
1029        assert!(!gone, "not while the request is under way");
1030        assert!(settle(&mut state, &closing), "put away once it is done");
1031        assert!(state.go(&closing, &SheetTo::initial()));
1032        assert_eq!(state.size(), 0.5);
1033        state.go(&closing, &SheetTo::size(0.0));
1034        assert!(settle(&mut state, &closing), "and may be put away again");
1035    }
1036
1037    fn settle(state: &mut SheetState, spec: &SheetSpec) -> bool {
1038        let mut dismissed = false;
1039        for _ in 0..240 {
1040            let (_, gone) = state.advance(spec, 1.0 / 60.0);
1041            dismissed |= gone;
1042        }
1043        dismissed
1044    }
1045
1046    /// **Released, a snapping sheet arrives on a stop**: the nearer one when let go
1047    /// slowly, the next one its way when flicked.
1048    #[test]
1049    fn a_released_sheet_arrives_on_a_stop() {
1050        let snapping = spec(true, false);
1051        let mut state = SheetState::new(&snapping);
1052        state.drag(&snapping, -0.2, 800.0); // 0.3: nearer the quarter than the half
1053        state.release(&snapping, 800.0, 0.0, None);
1054        assert!(state.is_settling());
1055        assert!(!settle(&mut state, &snapping));
1056        assert_eq!(state.size(), 0.25);
1057
1058        state.drag(&snapping, 0.05, 800.0); // 0.3 again, flicked up
1059        state.release(&snapping, 800.0, 300.0, None);
1060        settle(&mut state, &snapping);
1061        assert_eq!(state.size(), 0.5);
1062        assert!(!state.is_settling());
1063    }
1064
1065    /// **A flick down from the lowest stop dismisses**, once, and only when it can: a
1066    /// sheet without a message stops at `min`.
1067    #[test]
1068    fn a_flick_down_from_the_bottom_stop_dismisses_once() {
1069        let closing = spec(true, true);
1070        let mut state = SheetState::new(&closing);
1071        state.drag(&closing, -0.24, 800.0); // 0.26: a flick down goes to the quarter
1072        state.release(&closing, 800.0, -1200.0, None);
1073        assert!(
1074            !settle(&mut state, &closing),
1075            "the next stop its way, not nothing"
1076        );
1077        assert_eq!(state.size(), 0.25);
1078        state.drag(&closing, -0.01, 800.0); // 0.24: under the lowest stop that stays open
1079        state.release(&closing, 800.0, -1200.0, None);
1080        assert!(settle(&mut state, &closing), "dismissed");
1081        assert_eq!(state.size(), 0.0);
1082        assert!(!settle(&mut state, &closing), "and not twice");
1083
1084        let staying = spec(true, false);
1085        let mut state = SheetState::new(&staying);
1086        state.drag(&staying, -0.24, 800.0);
1087        state.release(&staying, 800.0, -1200.0, None);
1088        assert!(!settle(&mut state, &staying));
1089        assert_eq!(state.size(), 0.25);
1090    }
1091
1092    /// **A held sheet stays where the finger is**, even at nothing: it closes on the
1093    /// release, not under a finger that may yet bring it back.
1094    #[test]
1095    fn a_held_sheet_is_not_dismissed_under_the_finger() {
1096        let closing = spec(true, true);
1097        let mut state = SheetState::new(&closing);
1098        state.drag(&closing, -1.0, 800.0);
1099        assert_eq!(state.size(), 0.0);
1100        assert!(!settle(&mut state, &closing));
1101    }
1102
1103    /// **A coasting sheet goes where it is thrown** and stops at its own ends; a still
1104    /// release leaves it where it is.
1105    #[test]
1106    fn a_coasting_sheet_goes_where_it_is_thrown() {
1107        let coasting = spec(false, false);
1108        let mut state = SheetState::new(&coasting);
1109        state.drag(&coasting, 0.0, 800.0);
1110        state.release(&coasting, 800.0, 0.0, None);
1111        assert!(!state.is_settling());
1112        assert_eq!(state.size(), 0.5);
1113        state.release(&coasting, 800.0, 600.0, None);
1114        settle(&mut state, &coasting);
1115        assert!(
1116            state.size() > 0.55 && state.size() < 1.0,
1117            "{}",
1118            state.size()
1119        );
1120        state.release(&coasting, 800.0, 8000.0, None);
1121        settle(&mut state, &coasting);
1122        assert_eq!(state.size(), 1.0, "and stops at its max");
1123    }
1124
1125    /// **A sheet that leaves the frame forgets its height**, so shown again it starts
1126    /// from its initial one.
1127    #[test]
1128    fn a_sheet_that_leaves_the_frame_forgets_its_height() {
1129        let snapping = spec(true, false);
1130        let id = WidgetId::ROOT.child(0);
1131        let mut states = HashMap::new();
1132        drag_into(&mut states, id, &snapping, 0.3, 800.0);
1133        let area = SheetArea {
1134            id,
1135            panel: Rect::new(0.0, 0.0, 10.0, 10.0),
1136            available: 800.0,
1137            spec: snapping.clone(),
1138            areas: Vec::new(),
1139        };
1140        advance_all(&mut states, std::slice::from_ref(&area), 0.016);
1141        assert_eq!(size_of(&states, id, &snapping), 0.8);
1142        advance_all(&mut states, &[], 0.016);
1143        assert_eq!(size_of(&states, id, &snapping), 0.5);
1144    }
1145
1146    /// **The panel is as tall as the retained height, along the bottom, with the content
1147    /// stretched over it** — read off the scene.
1148    #[test]
1149    fn the_panel_sits_along_the_bottom_at_the_retained_height() {
1150        let red = Color::rgb(1.0, 0.0, 0.0);
1151        let sheet =
1152            || DraggableScrollableSheet::<()>::new(Container::new().color(red)).snap_sizes([0.5]);
1153        let painted = |runtime: &Runtime| {
1154            let root = sheet();
1155            let ui = build_ui(&root, Size::new(400.0, 800.0), runtime, &Theme::default());
1156            ui.scene().primitives().iter().find_map(|p| match p {
1157                Primitive::Rect { rect, color, .. } if *color == red => Some(*rect),
1158                _ => None,
1159            })
1160        };
1161        let at_rest = painted(&Runtime::default()).expect("the content is painted");
1162        assert_eq!(
1163            (at_rest.y, at_rest.height, at_rest.width),
1164            (400.0, 400.0, 400.0)
1165        );
1166
1167        let mut runtime = Runtime::default();
1168        let panel = WidgetId::ROOT.child(0);
1169        drag_into(&mut runtime.sheets, panel, &sheet().spec, 0.25, 800.0);
1170        let raised = painted(&runtime).expect("the content is painted");
1171        assert_eq!((raised.y, raised.height), (200.0, 600.0));
1172    }
1173
1174    /// Steps `state` for four seconds, and hands back the throw it passed on, if any.
1175    fn handed_over(state: &mut SheetState, spec: &SheetSpec) -> Option<(WidgetId, f32)> {
1176        let mut handed = None;
1177        for _ in 0..240 {
1178            state.advance(spec, 1.0 / 60.0);
1179            handed = handed.or(state.handover.take());
1180        }
1181        handed
1182    }
1183
1184    /// **A throw that reaches full height goes on into the list.** Milestone 515 stopped
1185    /// it at the top, and a list that was thrown with its sheet looked as if it had nothing
1186    /// more to show.
1187    #[test]
1188    fn a_throw_that_reaches_full_height_goes_on_into_the_list() {
1189        let list = WidgetId::ROOT.child(7);
1190        let tolerance = Tolerance::PIXELS.velocity;
1191
1192        // Snapping, flicked up from just above half: it arrives at the top at its constant
1193        // speed, and the list goes on at that speed. (A release exactly on a stop stays
1194        // on it, flick or not — the reference's rule too.)
1195        let snapping = spec(true, false);
1196        let mut state = SheetState::new(&snapping);
1197        state.drag(&snapping, 0.05, 800.0);
1198        state.release(&snapping, 800.0, 900.0, Some(list));
1199        let handed = handed_over(&mut state, &snapping);
1200        assert_eq!(state.size(), 1.0);
1201        assert_eq!(handed, Some((list, SHEET_SNAP_MIN_SPEED + tolerance)));
1202        assert!(
1203            !state.is_settling(),
1204            "the sheet is done; the list carries on"
1205        );
1206
1207        // Coasting, thrown hard: it reaches the top still moving, and hands over what it
1208        // has left — less than it was thrown with.
1209        let coasting = spec(false, false);
1210        let mut state = SheetState::new(&coasting);
1211        state.release(&coasting, 800.0, 8000.0, Some(list));
1212        let (to, velocity) = handed_over(&mut state, &coasting).expect("handed over");
1213        assert_eq!((to, state.size()), (list, 1.0));
1214        assert!(
1215            velocity > tolerance && velocity < 8000.0 + tolerance,
1216            "{velocity}"
1217        );
1218    }
1219
1220    /// **Only a throw up that arrives at the top hands anything over** — not one that
1221    /// settles lower, one going down, one with no list, nor a still release of a sheet
1222    /// already at the top, which still gets a snap at the snap's own speed.
1223    #[test]
1224    fn nothing_is_handed_over_short_of_a_throw_to_the_top() {
1225        let list = WidgetId::ROOT.child(7);
1226        let snapping = spec(true, true);
1227        let mut state = SheetState::new(&snapping);
1228
1229        state.drag(&snapping, -0.24, 800.0); // 0.26, flicked up: the next stop is half
1230        state.release(&snapping, 800.0, 300.0, Some(list));
1231        assert_eq!(handed_over(&mut state, &snapping), None);
1232        assert_eq!(state.size(), 0.5);
1233
1234        state.drag(&snapping, 0.45, 800.0); // near the top, flicked down to half
1235        state.release(&snapping, 800.0, -300.0, Some(list));
1236        assert_eq!(handed_over(&mut state, &snapping), None);
1237        assert_eq!(state.size(), 0.5);
1238
1239        state.drag(&snapping, 0.05, 800.0); // to the top, from no list
1240        state.release(&snapping, 800.0, 900.0, None);
1241        assert_eq!(handed_over(&mut state, &snapping), None);
1242        assert_eq!(state.size(), 1.0);
1243
1244        state.drag(&snapping, 0.0, 800.0); // at the top, let go of still
1245        state.release(&snapping, 800.0, 0.0, Some(list));
1246        assert_eq!(handed_over(&mut state, &snapping), None);
1247
1248        // At the top and flicked up: the sheet did not carry the throw anywhere, so it has
1249        // nothing to hand over — the list takes such a release itself, before the sheet.
1250        state.drag(&snapping, 0.0, 800.0);
1251        state.release(&snapping, 800.0, 900.0, Some(list));
1252        assert_eq!(handed_over(&mut state, &snapping), None);
1253
1254        state.drag(&snapping, -0.05, 800.0); // just under it, let go of still: a snap up
1255        state.release(&snapping, 800.0, 0.0, Some(list));
1256        assert_eq!(handed_over(&mut state, &snapping), None);
1257        assert_eq!(state.size(), 1.0);
1258    }
1259
1260    /// **A panel lowered to nothing keeps its list**: the list is how a finger holding it
1261    /// finds the sheet on every movement, and a finger that lowered the sheet to nothing
1262    /// through its list may yet bring it back.
1263    #[test]
1264    fn a_panel_lowered_to_nothing_keeps_its_list() {
1265        let sheet = || {
1266            DraggableScrollableSheet::<()>::new(
1267                crate::Flex::column().flex(1.0).child(
1268                    crate::SingleChildScrollView::new()
1269                        .flex(1.0)
1270                        .child(Container::new().height(2000.0)),
1271                ),
1272            )
1273            .min(0.25)
1274            .on_dismiss(())
1275        };
1276        let holding = |runtime: &Runtime| {
1277            let root = sheet();
1278            let ui = build_ui(&root, Size::new(400.0, 800.0), runtime, &Theme::default());
1279            let area = ui
1280                .sheets()
1281                .first()
1282                .cloned()
1283                .expect("the sheet is in the frame");
1284            let held = area
1285                .areas
1286                .first()
1287                .and_then(|list| ui.sheet_holding(*list))
1288                .map(|sheet| sheet.id);
1289            (area.areas.len(), held == Some(area.id))
1290        };
1291        let mut runtime = Runtime::default();
1292        let panel = WidgetId::ROOT.child(0);
1293        assert_eq!(
1294            holding(&runtime),
1295            (1, true),
1296            "open, its list shares the finger"
1297        );
1298        drag_into(&mut runtime.sheets, panel, &sheet().spec, -0.5, 800.0);
1299        assert_eq!(runtime.sheets[&panel].size(), 0.0);
1300        assert_eq!(holding(&runtime), (1, true), "at nothing, still");
1301    }
1302}