Component · Bottom Sheet

Bottom Sheet

El overlay más usado en MiCapi. Es el contenedor universal para confirmaciones (compra, retiro, swap), selectores (activo, método de pago) y avisos expandibles. Todo lo que en desktop sería modal o popover, en una app mobile-only es un bottom sheet. La spec de motion vive en foundations-motion §3c — esta página define el container, sus comportamientos y sus slots.

Overlay universal radius 16dp (top) · padding 16dp enter: drawer · exit: accelerate role: dialog
Anatomy

Estructura del container

Seis elementos. El sheet siempre vive sobre un scrim y siempre tiene el handle arriba — incluso cuando no es draggable, el handle indica visualmente "esto es un sheet, no una pantalla".

Comprar BTC
Vas a pagar
$500.000 COP
Confirmar compra
0,00109 BTC
≈ $500.000 COP
Tasa$458.320k / BTC
Comisión1,5%
1

Backdrop / scrim

Fondo negro con opacity 0.5 + blur sutil (backdrop-filter: blur(2px)). Cuando el sheet es dismissible, el scrim es tappable y cierra. Semantic label: "Cerrar".

2

Handle de drag

Píldora 40dp × 4dp, color --color-neutral-300 (light) / --color-neutral-600 (dark), radius 2dp. La zona de toque es la franja completa de 40dp de alto — no solo el píldora visual.

3

Header slot · fijo

Título + acción de cierre opcional. Alto 56dp. Cuando el body scrollea, aparece un divider hairline 1dp debajo del header para separarlo visualmente.

4

Body slot · scrollea si excede

Contenido principal. Padding horizontal 16dp. SingleChildScrollView envuelve el body cuando el contenido supera el alto disponible. Header y footer permanecen fijos.

5

Footer slot · fijo

Botones de acción. Divider hairline arriba. Respeta safe area inferior — padding bottom suma MediaQuery.padding.bottom para no chocar con la barra de gesture nav.

6

Esquinas superiores

border-radius: 16dp top-left + top-right. Abajo cuadrado — el sheet toca el borde inferior de la pantalla, no flota.

Behavior

Tres comportamientos

El mismo container; cambian las reglas de dismiss. Esta decisión nunca es estética — la define la naturaleza de la acción que el sheet contiene.

NO DISMISSIBLE

bModal persistente

No se cierra con scrim ni con swipe. Solo con acción explícita: Confirmar o Cancelar. El usuario tiene que tomar una decisión.

01 · abierto
Confirmar compra
0,00109 BTC
No hay X · no se cierra con swipe
02 · scrim tap
Confirmar compra
0,00109 BTC
SIN EFECTO
Tap en scrim no cierra · sheet permanece
03 · cancel
Confirmar compra
0,00109 BTC
Tap explícito en "Cancelar" → cierra
Dismiss por
Solo acción explícita: botón "Confirmar" o "Cancelar"
Scrim · swipe · back
Bloqueados. Back de Android no hace nada.
Flutter
showModalBottomSheet(isDismissible: false, enableDrag: false) + PopScope(canPop: false)
Uso
Confirmación de compra · confirmación de retiro · confirmación de swap · cualquier acción que mueva dinero
Regla. Las confirmaciones financieras nunca son dismissible. Una compra accidental por swipe es mucho peor que el fricción de leer y tocar "Cancelar".
Slots

Slots de contenido

El sheet define dónde va el contenido, no qué va. Los slots son tres: header (fijo arriba), body (scrollea), footer (fijo abajo). Los patterns concretos — confirmation, selector — se construyen rellenando estos slots.

handle zone · 40dp
Header slot FIJO
Body slot SCROLLEA
Scroll vertical si excede
H

Header FIJOOPCIONAL

Título de la acción + botón de cierre. 56dp de alto. Permanece arriba cuando el body scrollea. Aparece un divider 1dp debajo del header en cuanto el body se desplaza desde el top.

B

Body SCROLLEA

El contenido principal. Padding horizontal 16dp. SingleChildScrollView cuando excede el alto disponible. Header y footer no se mueven — solo el body.

Examples

Tres ejemplos en pantalla

Validación del container. Los patterns oficiales (Confirmation, Selector) se especifican aparte — esto solo prueba que la estructura escala bien con contenido real.

aSelector de activo
9:41
Comprar
Vas a comprar
Selecciona un activo
Vas a pagar
$500.000
Seleccionar activo
₿
Bitcoin
BTC
$458.320k
+2,3%
Ethereum
ETH
$3.645k
+0,8%
Tether
USDT
$4.012
+0,01%
DISMISSIBLE Sheet corto (~30% del alto). Sin footer — la selección de un activo es la acción. Tap en scrim cierra sin elegir.
bConfirmation de compra
9:41
Comprar BTC
Vas a comprar
0,00109 BTC
Vas a pagar
$500.000
Confirmar compra
0,00109 BTC
≈ $500.000 COP
Tasa$458.320.000 / BTC
Comisión MiCapi (1,5%)$7.500
Total a pagar$507.500
Plazo de liquidación24h hábiles
PERSISTENTE Sheet medio (~55%). Handle visualmente más sutil para no insinuar drag. Sin X — solo "Confirmar" o "Cancelar".
cDetalle de transacción
9:41
Historial
Compra BTC
+0,00109 BTC
10%
50%
90%
Detalle de transacción
+0,00109 BTC
$500.000 COP · Hoy 9:12
TipoCompra
EstadoCompletada
Tasa$458.320.000
Comisión$7.500
ReferenciaMC-78412-OP
Hash blockchain 0xa12…f3b
Método de pagoNequi · ***41
EXPANDABLE Snap points [0.1, 0.5, 0.9] indicados con líneas guía. Aquí se muestra a 90%, completamente abierto.
Motion

Interacciones y motion

Todos los valores vienen de foundations-motion. No hay haptic al abrir o cerrar el sheet — el haptic va dentro, en las acciones que el sheet contiene.

Momento
Tokens
Comportamiento
Enter open
duration-modal
+ ease-drawer
Slide up desde translateY 100% hasta 0. Backdrop fade 0 → 0.5 simultáneo. 300ms · Curves.easeOutCubic.
Exit close
duration-modal
+ ease-accelerate
Slide down hasta 100%. Backdrop fade 0.5 → 0. La asimetría con el enter es intencional: salir se siente decidido.
Drag
Duration.zero
Tracking 1:1 con el dedo. Sin animación. Si late aunque sea 16ms, se siente atascado. La interpolación es position = drag.deltaY.
Snap final
duration-default
+ ease-drawer
Después del drag, animación al snap point más cercano. 200ms · Curves.easeOutCubic.
Tap en scrim
— exit tokens —
Cuando es dismissible: dispara el exit. Cuando no: no hace nada. Sin shake, sin pulse de feedback — el silencio es la respuesta correcta.
Reduced motion
duration-default
+ opacity only
Si MediaQuery.disableAnimationsOf(context): reemplaza slide por fade 200ms. Backdrop sigue oscureciendo igual.
Accessibility

Accesibilidad

El sheet es un dialog semánticamente. Trap de foco, labels descriptivos, contraste AA, y siempre una ruta de cierre clara.

Role: dialog

Semantics(label: 'Confirmar compra', container: true, scopesRoute: true, ...). TalkBack y VoiceOver anuncian la apertura con el título del header.

Trap de foco

Mientras el sheet está abierto, Tab/keyboard nav queda dentro. Al cerrar, el foco vuelve al elemento que abrió el sheet — no se pierde.

Handle · semantic label

"Arrastrar para ajustar" cuando el sheet es expandable. En sheets no-draggable el handle es excludeSemantics: true — no anunciar lo que no se puede hacer.

Backdrop · semantic label

"Cerrar" cuando es dismissible. En persistente, el scrim excludeSemantics — no anunciar una acción inexistente.

Contraste AA

Texto sobre superficie del sheet: contraste mínimo 4.5:1. Verificado para light y dark. Los chips de variación porcentual usan los semánticos del DS (success-fg, error-fg).

Back button de Android

Dismissible: cierra el sheet (PopScope default). Persistente: bloqueado — el usuario debe usar "Cancelar". Bloquear con PopScope(canPop: false).

Dark mode

Claro y oscuro

El scrim mantiene los mismos valores en ambos modos (rgba negro 0.5 + blur). Cambian la superficie del sheet, el color del handle y los tokens semánticos.

LightDEFAULT
9:41
Comprar BTC
Vas a pagar
$500.000
Confirmar compra
0,00109 BTC
≈ $500.000 COP
Tasa$458.320k
Comisión$7.500
Total$507.500
DarkDARK MODE
9:41
Comprar BTC
Vas a pagar
$500.000
Confirmar compra
0,00109 BTC
≈ $500.000 COP
Tasa$458.320k
Comisión$7.500
Total$507.500
Edge cases

Casos límite

Tres situaciones que se rompen sin pensarlo. La que más se ve en producción es la del scroll — vale la pena demostrarla con un sheet de 10 ítems.

9:41
Métodos de pago
Elegir método de pago
Bre-B
Instantáneo · sin costo
N
Nequi
Instantáneo · sin costo
PSE
Hasta 2h · $1.500
Bancolombia
Transferencia · $0
Davivienda
Transferencia · $0
BBVA
Transferencia · $0
Banco de Bogotá
Transferencia · $0
Banco Popular
Transferencia · $0
Banco AV Villas
Transferencia · $0
Banco Caja Social
Transferencia · $0

Contenido que excede el alto

Ejemplo a la izquierda: 10 métodos de pago. El body scrollea pero el header con título y X, y el footer con el CTA, permanecen fijos. Apenas el body se separa del top, aparece un divider 1dp debajo del header.

Teclado abierto

Si el sheet contiene un TextField: Flutter lo maneja vía resizeToAvoidBottomInset: true en el Scaffold. El sheet se redimensiona automáticamente para no quedar tapado. El footer se mantiene visible justo encima del teclado.

Landscape

No es prioritario (la app es portrait-primary). Si ocurre: el sheet limita su ancho a 70% máximo del ancho de pantalla y se centra horizontalmente. El scrim sigue cubriendo todo. Usar MediaQuery.orientation para el ajuste.

Implementation

Notas de implementación Flutter

Construcción de un bottom sheet

Modal básico

showModalBottomSheet con isScrollControlled: true + constraints: BoxConstraints(maxHeight: ...). El shape define el radius superior. Background con --surface.

Persistente (no dismissible)

isDismissible: false + enableDrag: false + envolver el body en PopScope(canPop: false) para bloquear el back de Android. Los botones explícitos son la única salida.

Expandable con snap

DraggableScrollableSheet(controller, snap: true, snapSizes: [0.3, 0.5, 0.9]). El scrollController interno permite scrollear el body sin interferir con el drag del sheet.

Header fijo + body scroll + footer fijo

Column con tres hijos: SheetHeader, Expanded(child: SingleChildScrollView(body)), SheetFooter. El Expanded es lo que permite que solo el medio scrollee.

// Confirmation sheet — persistente, no dismissible
await showModalBottomSheet<bool>(
  context: context,
  isScrollControlled: true,
  isDismissible: false,           // scrim no cierra
  enableDrag: false,             // swipe no cierra
  backgroundColor: Colors.transparent,
  builder: (ctx) => PopScope(
    canPop: false,                // back de Android tampoco
    child: Container(
      decoration: BoxDecoration(
        color: Theme.of(ctx).colorScheme.surface,
        borderRadius: const BorderRadius.vertical(
          top: Radius.circular(16),
        ),
      ),
      child: SafeArea(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            SheetHandle(),                // visualmente sutil — no es draggable
            SheetHeader(title: 'Confirmar compra'),
            Flexible(
              child: SingleChildScrollView(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: BuyConfirmBody(amount: amount),
              ),
            ),
            SheetFooter(
              primary: PrimaryCta(
                label: 'Confirmar compra',
                onTap: () {
                  HapticFeedback.mediumImpact();
                  Navigator.pop(ctx, true);
                },
              ),
              secondary: SecondaryLink(
                label: 'Cancelar',
                onTap: () => Navigator.pop(ctx, false),
              ),
            ),
          ],
        ),
      ),
    ),
  ),
);