pub version likes popularity license

morphing_sheet

A production-ready Flutter package for building spatially continuous, physics-based morphing interactions. Transform any widget — from a card, grid item, or list tile — into a fullscreen detail view with gesture-driven, deterministic animations. Zero dependencies beyond Flutter itself.

Inspired by Swiggy Instamart, Apple's container transforms, and Material Motion.


Table of Contents


Overview

morphing_sheet provides a unified spatial morphing system for Flutter. At its core is MorphEngine — a pure-function interpolation engine that maps a single progress value (0.0 → 1.0) to every visual property: rect, radius, elevation, blur, scale, and content crossfade. This ensures all morph transitions are deterministic (State = f(progress)), with no implicit animation widgets, no AnimatedContainer, and no rebuild storms.

The package ships with three ready-to-use interaction paradigms, each built on top of the shared core engine:

Paradigm Import Best For
MorphingCard morphing_surface.dart Single card → fullscreen (simplest API)
MorphingCardCarousel morphing_card_carousel.dart Horizontal card carousel with vertical fullscreen morph
MorphingOverlay morphing_overlay.dart Instamart-style: Grid item → floating card → fullscreen with sticky header

Key Features

🎯 Core Architecture

  • Single source of truth — One AnimationController drives all visual properties; no widget-level animations.
  • Deterministic state machineState = f(progress). No hidden state, no implicit UI mutations.
  • Pure-function engineMorphEngine is entirely static, side-effect free, and testable.
  • State-management agnostic — All controllers extend ChangeNotifier; works with Provider, Riverpod, Bloc, or vanilla ListenableBuilder.
  • Zero external dependencies — Only flutter/widgets.dart and flutter/material.dart.

🎨 Visual Effects

  • Rect morphing — Pixel-perfect interpolation from source widget to fullscreen.
  • Corner radius animation — Rounded card → zero-radius fullscreen.
  • Peak-and-drop elevation — Shadow peaks mid-transition for a physical "lift" effect.
  • Background scrim — Semi-transparent barrier with configurable opacity.
  • Background blur — Gaussian blur that intensifies with expansion.
  • Background scale — Subtle zoom-out of underlying content during morph.
  • Content crossfade — Collapsed content fades out, expanded content fades in, with configurable timing.
  • Shared element transitions — Tag-based MorphShared widget for per-element animation (Hero-like, scoped to container).

🖐 Gesture System

  • Direction-locking — 16px threshold before axis is locked (horizontal vs. vertical).
  • Rubber-band resistance — Elastic boundary feedback when dragging past limits.
  • Velocity-aware snapping — Flick gestures resolve snap targets based on speed.
  • Drag-to-collapse — Pull down from fullscreen to dismiss.
  • Two-axis support — Simultaneous horizontal carousel swiping + vertical morph expansion.
  • Back button integrationPopScope consumption when expanded.

⚡ Performance

  • RepaintBoundary isolation — Separate paint layers for background, scrim, morph surface, and content.
  • Lazy content rendering — Only builds visible content (collapsed OR expanded, never both at rest).
  • No AnimatedOpacity / AnimatedContainer — Raw Opacity and Transform widgets for zero overhead.
  • Opacity culling — Content layers with opacity < 0.01 are excluded from the widget tree entirely.

Three Interaction Paradigms

MorphingCard

Tap a card → it expands from its exact on-screen position to fullscreen. Drag down or press back to collapse. Renders via Flutter's Overlay for correct z-ordering.

MorphingCardCarousel

A centered card carousel with horizontal swiping between items and vertical drag-to-fullscreen. Includes a circular thumbnail selector bar. Custom CarouselScrollPhysics (no PageView physics).

MorphingOverlay

Instamart-style contextual morph: tap a grid/list item → it morphs from its exact position into a centered floating card (with a bottom thumbnail selector) → drag up to fullscreen detail (with a sticky header). Seven-phase state machine: idle → appearing → cardMode → expanding → fullscreen → collapsing → disappearing → idle.


Installation

dependencies:
  morphing_sheet: ^0.6.0
// Pick the import for your use case:
import 'package:morphing_sheet/morphing_surface.dart';        // MorphingCard + MorphOverlayManager
import 'package:morphing_sheet/morphing_card_carousel.dart';  // MorphingCardCarousel
import 'package:morphing_sheet/morphing_overlay.dart';        // MorphingOverlay (grid → card → full)

Quick Start

1. MorphingCard — Simplest

import 'package:morphing_sheet/morphing_surface.dart';

class ProductGrid extends StatefulWidget {
  @override
  State<ProductGrid> createState() => _ProductGridState();
}

class _ProductGridState extends State<ProductGrid>
    with SingleTickerProviderStateMixin {
  late final MorphController _controller;

  @override
  void initState() {
    super.initState();
    _controller = MorphController(vsync: this);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MorphingFrame(
      controller: _controller,
      child: Scaffold(
        body: MorphingCard(
          controller: _controller,
          collapsedBuilder: (context, progress) => ProductCard(product),
          expandedBuilder: (context, progress) => ProductDetailPage(product),
          duration: const Duration(milliseconds: 450),
          dragToCollapse: true,
        ),
      ),
    );
  }
}

2. MorphingCardCarousel

import 'package:morphing_sheet/morphing_card_carousel.dart';

MorphingCardCarousel<Product>(
  items: products,
  cardBuilder: (context, item, progress, index, isActive) {
    return ProductCard(item: item);
  },
  fullscreenBuilder: (context, item, progress) {
    return ProductDetail(item: item);
  },
  thumbnailBuilder: (context, item, isActive) {
    return Image.network(item.thumbUrl, fit: BoxFit.cover);
  },
)

3. MorphingOverlay (Flagship Demo)

The premium "Instamart-style" contextual morph. Tap a grid/list item → it morphs from its exact position into a centered floating card (with carousel swiping) → drag up to fullscreen detail.

Features a 7-phase state machine: idle → appearing → cardMode → expanding → fullscreen → collapsing → disappearing → idle.

import 'package:morphing_sheet/morphing_overlay.dart';

// 1. Create controller (requires TickerProviderStateMixin)
final controller = OverlayMorphController(
  vsync: this,
  flickVelocityThreshold: 500.0, // Soft, premium physics
);

// 2. Wrap your layout with MorphingOverlay
MorphingOverlay<Product>(
  controller: controller,
  items: products,
  maxRadius: 16,
  maxElevation: 20,
  cardBuilder: (context, item, progress) => ProductCard(item: item, progress: progress),
  detailBuilder: (context, item, progress) => ProductDetail(item: item, progress: progress),
  child: CustomScrollView(
    slivers: [
      SliverGrid.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          // 3. Wrap each item with MorphingSource
          return MorphingSource<Product>(
            controller: controller,
            item: products[index],
            itemIndex: index,
            child: ProductTile(products[index]),
          );
        },
      ),
    ],
  ),
)

Premium add-ons included in the example app:

  • HeroProductImage: Shared visual element linking the grid, card, and sticky header.
  • CarouselIndexSelector: Bottom-drawer thumbnail scroll bar that automatically hides during morphing.
  • Card Lift: Elevation scaling during the transition to simulate a physical "lift" effect.

4. MorphOverlayManager — Imperative

import 'package:morphing_sheet/morphing_surface.dart';

final manager = MorphOverlayManager(vsync: this);

// On tap:
manager.show(
  context: context,
  collapsedBuilder: (ctx, p) => ProductTile(product),
  expandedBuilder: (ctx, p) => ProductDetail(product),
);

// On dismiss:
manager.hide();

Architecture

Package Structure

lib/
 ├── morphing_surface.dart          # Barrel: core engine + MorphingCard + MorphOverlayManager
 ├── morphing_card_carousel.dart    # Barrel: MorphingCardCarousel + CardCarouselController
 ├── morphing_overlay.dart          # Barrel: MorphingOverlay + OverlayMorphController
 └── src/
      ├── core/                     # Shared foundation
      │    ├── morph_engine.dart     #   Pure-function interpolation (rect, radius, elevation, blur...)
      │    ├── morph_controller.dart #   Unified AnimationController wrapper + drag support
      │    ├── morph_phase.dart      #   MorphPhase enum + MorphState immutable snapshot
      │    ├── morph_origin.dart     #   MorphOrigin (source rect capture) + MorphOriginType
      │    └── morph_shared.dart     #   MorphShared tag-based shared element widget
      ├── controller/
      │    └── card_carousel_controller.dart  # Carousel-specific controller (vertical + horizontal)
      ├── models/
      │    ├── card_item.dart        #   CardItem<T> stable-identity data wrapper
      │    └── card_state.dart       #   CardCarouselState + CardPhase enum
      ├── animations/
      │    ├── card_morph_tween.dart  #   Card-specific interpolation helpers (delegates to MorphEngine)
      │    └── rect_morph_tween.dart  #   Rect-based overlay interpolation helpers
      ├── physics/
      │    └── carousel_physics.dart  #   CarouselScrollPhysics + CarouselSnapResolver
      ├── gestures/
      │    └── morph_drag_controller.dart  #  Direction-locking + rubber-band drag handling
      ├── overlay/
      │    ├── overlay_controller.dart        # OverlayMorphController (two AnimationControllers)
      │    ├── overlay_state.dart             # OverlayMorphState + OverlayPhase (7 phases)
      │    ├── overlay_morphing_container.dart # MorphingOverlay<T> container widget
      │    ├── morphing_source.dart           # MorphingSource<T> tap-capture wrapper
      │    ├── overlay_gesture_layer.dart     # Overlay gesture disambiguation
      │    └── morph_overlay_manager.dart     # Imperative MorphOverlayManager
      └── widgets/
           ├── morphing_surface.dart     # Base morph surface (rect + radius + elevation + crossfade)
           ├── morphing_card.dart        # MorphingCard flagship widget
           ├── morphing_frame.dart       # Background scale + blur wrapper
           ├── card_container.dart       # MorphingCardCarousel<T> main widget
           ├── card_stack_layer.dart      # Positioned active + adjacent card rendering
           ├── thumbnail_selector_bar.dart # Horizontally scrollable circular thumbnails
           ├── gesture_layer.dart        # Carousel gesture disambiguation
           └── background_layer.dart     # Scale + blur + barrier background

Total: 26 source files | 8 test files | 4 example apps

Layer Responsibilities

Layer Purpose
Core MorphEngine (pure math), MorphController (animation driver), MorphPhase/MorphState (state machine), MorphOrigin (geometry capture), MorphShared (shared elements)
Models Immutable state snapshots (CardCarouselState, OverlayMorphState), identity wrappers (CardItem<T>)
Animations Per-paradigm interpolation helpers (CardMorphTween, RectMorphTween) — all delegate to MorphEngine
Physics CarouselScrollPhysics (custom snap physics), CarouselSnapResolver (deterministic index resolution)
Gestures MorphDragController (direction-locking, rubber-band), CardGestureLayer, OverlayGestureLayer
Controllers CardCarouselController, OverlayMorphController — paradigm-specific state management
Widgets Presentation: MorphingSurface, MorphingCard, MorphingFrame, MorphingCardCarousel, MorphingOverlay

Core Engine — MorphEngine

The engine is an abstract final class with only static methods. Every visual property is a pure function of progress:

// Rect geometry
Rect  rect      = MorphEngine.morphRect(progress, startRect, endRect);
double left     = MorphEngine.morphLeft(progress, startLeft, endLeft);
double top      = MorphEngine.morphTop(progress, startTop, endTop);
double width    = MorphEngine.morphWidth(progress, startWidth, endWidth);
double height   = MorphEngine.morphHeight(progress, startHeight, endHeight);

// Visual properties
double radius    = MorphEngine.morphRadius(progress, startRadius: 16, endRadius: 0);
double elevation = MorphEngine.morphElevation(progress, peakElevation: 16);  // peaks at 0.5
double scrim     = MorphEngine.morphScrim(progress, maxOpacity: 0.5);
double blur      = MorphEngine.morphBlur(progress, maxBlur: 6.0);
double scale     = MorphEngine.morphScale(progress, minScale: 0.95);

// Content crossfade
double sourceAlpha = MorphEngine.sourceContentOpacity(progress);  // 1.0 → 0.0
double targetAlpha = MorphEngine.targetContentOpacity(progress);  // 0.0 → 1.0

State Machine

MorphPhase (core — used by MorphingCard and MorphOverlayManager):

collapsed → expanding → expanded → collapsing → collapsed
                              ↕
                          dragging

CardPhase (carousel):

collapsedCard → transitioningToFull → fullscreen → transitioningToCard → collapsedCard

OverlayPhase (overlay — 7 phases):

idle → appearing → cardMode → expanding → fullscreen → collapsing → cardMode → disappearing → idle

Controllers

Controller Extends AnimationControllers Used By
MorphController ChangeNotifier 1 MorphingCard, MorphOverlayManager, MorphingFrame
CardCarouselController ChangeNotifier 1 MorphingCardCarousel
OverlayMorphController ChangeNotifier 2 (appear + expand) MorphingOverlay

API Reference

MorphController API

// Lifecycle
final controller = MorphController(
  vsync: this,
  expandDuration: const Duration(milliseconds: 420),
  collapseDuration: const Duration(milliseconds: 350),
  expandCurve: Curves.easeInOutCubic,
  collapseCurve: Curves.easeInOutCubic,
);

// Programmatic control
controller.expandFrom(origin: origin, targetRect: fullscreenRect);
controller.expand();
controller.collapse();

// Interactive drag
controller.startDrag();
controller.updateDrag(delta, availableHeight);
controller.endDrag(velocityPxPerSecond, availableHeight);

// Read-only state
controller.progress;     // 0.0 → 1.0
controller.phase;        // MorphPhase enum
controller.state;        // MorphState immutable snapshot
controller.origin;       // MorphOrigin?
controller.targetRect;   // Rect?
controller.currentRect;  // Rect? (interpolated at current progress)
controller.isCollapsed;  // bool
controller.isExpanded;   // bool
controller.isAnimating;  // bool
controller.isDragging;   // bool

CardCarouselController API

final ctrl = CardCarouselController(vsync: this, itemCount: items.length);

// Vertical morph
ctrl.expand();
ctrl.collapse();
ctrl.onVerticalDragStart();
ctrl.onVerticalDragUpdate(delta, availableHeight);
ctrl.onVerticalDragEnd(velocity, availableHeight);

// Horizontal carousel
ctrl.setIndex(index);
ctrl.animateToIndex(index);
ctrl.resolveHorizontalSnap(velocity: v, displacement: d, cardWidth: w);

// State
ctrl.verticalProgress;  // 0.0 → 1.0
ctrl.currentIndex;      // int
ctrl.phase;             // CardPhase
ctrl.state;             // CardCarouselState

OverlayMorphController API

// Requires TickerProviderStateMixin (two AnimationControllers)
final ctrl = OverlayMorphController(vsync: this);

// Selection
ctrl.selectItem(item, itemIndex: 0, originRect: rect, screenSize: size);
ctrl.dismiss();

// Vertical expand/collapse
ctrl.expand();
ctrl.collapse();

// Horizontal carousel
ctrl.setIndex(index);
ctrl.onHorizontalDragUpdate(delta);
ctrl.onHorizontalDragEnd(velocity, cardWidth);

// Back button
ctrl.handleBackButton();  // returns bool (consumed or not)

// State
ctrl.state;              // OverlayMorphState
ctrl.overlayProgress;    // appear/disappear: 0.0 → 1.0
ctrl.expandProgress;     // card→fullscreen: 0.0 → 1.0
ctrl.currentIndex;       // int
ctrl.phase;              // OverlayPhase (7 phases)
ctrl.selectedItem;       // Object?
ctrl.hasSelection;       // bool

Configuration & Customization

MorphingCard

MorphingCard(
  controller: controller,
  collapsedBuilder: (context, progress) => CardContent(),
  expandedBuilder: (context, progress) => DetailContent(),
  duration: const Duration(milliseconds: 450),       // expand/collapse duration
  expandCurve: Curves.easeInOutCubic,
  collapseCurve: Curves.easeInOutCubic,
  borderRadius: 16.0,                                // source corner radius
  maxElevation: 12.0,                                // peak shadow elevation
  surfaceColor: Colors.white,                         // card background
  dragToCollapse: true,                               // enable drag-down dismiss
  enableSharedElements: false,                        // MorphShared tag animations
  onExpanded: () {},                                  // callback
  onCollapsed: () {},                                 // callback
)

MorphingFrame (Background Effects)

MorphingFrame(
  controller: controller,
  minScale: 0.94,     // background scale when fully expanded
  maxBlur: 5.0,       // background blur intensity
  child: Scaffold(...),
)

MorphingCardCarousel

MorphingCardCarousel<T>(
  items: items,
  cardBuilder: (context, item, progress, index, isActive) => ...,
  fullscreenBuilder: (context, item, progress) => ...,
  thumbnailBuilder: (context, item, isActive) => ...,
  backgroundBuilder: (context, progress, index) => ...,
  controller: controller,                              // optional external controller
  cardHeightFraction: 0.75,
  cardWidthFraction: 0.85,
  maxRadius: 28.0,
  maxElevation: 12.0,
  expandDuration: const Duration(milliseconds: 350),
  collapseDuration: const Duration(milliseconds: 300),
  expandCurve: Curves.easeInOutCubic,
  collapseCurve: Curves.easeInOutCubic,
)

MorphOverlayManager

MorphOverlayManager(
  vsync: this,
  borderRadius: 16.0,
  maxElevation: 16.0,
  scrimOpacity: 0.5,
  expandDuration: const Duration(milliseconds: 420),
  collapseDuration: const Duration(milliseconds: 420),
  expandCurve: Curves.easeInOutCubic,
  collapseCurve: Curves.easeInOutCubic,
)

Animation System

All animations are driven by pure-function interpolation. No AnimatedWidget subclasses.

Interpolation Helpers

Class Scope Key Methods
MorphEngine Universal morphRect, morphRadius, morphElevation, morphScrim, morphBlur, morphScale, sourceContentOpacity, targetContentOpacity
CardMorphTween Carousel morphHeight, morphWidth, morphRadius, thumbnailOpacity, cardContentOpacity, fullscreenContentOpacity, adjacentCardScale
RectMorphTween Overlay morphRect, morphElevation (peak-drop), computeCardRect, overlayScrimOpacity, overlayBlur, overlayBackgroundScale, adjacentCardOpacity

Content Crossfade Timing

progress:  0.0 ──── 0.35 ──── 0.4 ──── 0.7 ──── 1.0
source:    ████████████████████░░░░░░░░░░░░░░░░░░░░
target:    ░░░░░░░░░░░░░░░░░░██████████████████████
                         ↑ overlap zone (0.35–0.4)

Elevation Peak-and-Drop

progress:  0.0 ─── 0.5 ─── 1.0
elevation: 4 ─── 16 ─── 0
                ↑ peak (mid-transition lift effect)

Physics & Gestures

CarouselScrollPhysics

Custom ScrollPhysics implementation (not PageScrollPhysics) with:

  • Velocity-aware snapping — Flick above 600 px/s triggers immediate index change.
  • Distance threshold — 30% card width drag triggers snap without velocity.
  • Spring simulation — mass: 0.8, stiffness: 120, damping: 14.

CarouselSnapResolver

Deterministic index resolution from velocity + displacement:

int targetIndex = CarouselSnapResolver.resolve(
  currentIndex: current,
  velocity: velocity,
  displacement: displacement,
  cardWidth: width,
  itemCount: count,
  velocityThreshold: 600.0,    // px/s
  distanceThreshold: 0.3,      // fraction of card width
);

MorphDragController

Enhanced drag handler wrapping MorphController:

  • Direction-locking — 12px threshold before axis commit.
  • Rubber-band — 0.25× input scalar at boundaries.
  • Axis tracking — Exposed via isVerticalLocked / isHorizontalLocked.

Velocity Snap Resolution (Vertical)

const velocityThreshold = 1.5;   // normalized units/s
const progressThreshold = 0.5;   // progress midpoint

if (normVelocity.abs() > velocityThreshold) {
  shouldExpand = normVelocity > 0;    // fast flick decides
} else {
  shouldExpand = progress >= 0.5;     // position decides
}

Performance

Technique Where
RepaintBoundary isolation Background, scrim, morph surface, content layers
Opacity culling (< 0.01) MorphingSurface._ContentCrossfade, MorphOverlayManager._buildEntry
Single-child rendering at rest Only collapsed OR expanded content built when progress is 0.0 or 1.0
Raw Opacity + Transform No AnimatedOpacity, no AnimatedContainer
ImageFiltered only when blur > 0.05 MorphingFrame.build
IgnorePointer on invisible scrim MorphOverlayManager._buildEntry

Testing

The package includes 8 test files covering:

Test File Coverage
morph_engine_test.dart Pure-function interpolation correctness
morph_controller_test.dart Expand, collapse, drag, velocity snap
overlay_controller_test.dart 7-phase overlay state machine
rect_morph_tween_test.dart Rect-based interpolation helpers
default_physics_test.dart Physics engine behavior
snap_resolver_test.dart Deterministic snap resolution
sheet_controller_test.dart Legacy sheet controller (v0.1/v0.2)
morphing_sheet_test.dart Widget-level rendering

Run tests:

flutter test

Version History

Version Highlights
0.6.0 Current — unified MorphEngine, MorphController, MorphingCard, MorphingSurface, MorphingFrame, MorphShared, MorphOverlayManager
0.5.0 Overlay Carousel Mode — two AnimationControllers, 7-phase state machine, horizontal carousel in overlay
0.4.0 Overlay Rect Mode — Instamart-style contextual morph from grid/list items
0.3.0 Card Carousel — centered card-based morph with thumbnail strip
0.2.0 List-driven mode — item selection, preview/detail layers, magnetic snap
0.1.0 Initial — bottom sheet with multi-stage snap points

See CHANGELOG.md for detailed migration guides.


Minimum Requirements

  • Flutter >= 3.10.0
  • Dart >= 3.10.4

License

MIT — see LICENSE for details.

Libraries

A centered morphing card carousel with horizontal sliding cards, vertical fullscreen morph, circular thumbnail selector strip, and deterministic physics-based animation.
morphing_overlay
Contextual overlay-driven morphing card transitions.
morphing_surface
Spatial morphing system for Flutter — entry point for the core engine, MorphingCard, MorphingSurface, and overlay manager.