Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active April 2, 2025 16:47
Show Gist options
  • Select an option

  • Save slightfoot/a6b5e17e664567861d7cf0c0598f7c0d to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/a6b5e17e664567861d7cf0c0598f7c0d to your computer and use it in GitHub Desktop.

Revisions

  1. slightfoot revised this gist Nov 1, 2023. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions animated_partial_height_sheet.dart
    Original file line number Diff line number Diff line change
    @@ -280,6 +280,7 @@ class RenderPartialHeightLayout extends RenderBox
    height = children[index].size.height;
    }

    // Align each child vertically within the layout height
    final pageRect = Rect.fromLTWH(0, 0, viewportWidth, height);
    for (final child in children) {
    final parentData = child.parentData as _PartialHeightLayoutParentData;
  2. slightfoot revised this gist Nov 1, 2023. No changes.
  3. slightfoot created this gist Nov 1, 2023.
    315 changes: 315 additions & 0 deletions animated_partial_height_sheet.dart
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,315 @@
    // MIT License
    //
    // Copyright (c) 2023 Simon Lightfoot
    //
    // Permission is hereby granted, free of charge, to any person obtaining a copy
    // of this software and associated documentation files (the "Software"), to deal
    // in the Software without restriction, including without limitation the rights
    // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    // copies of the Software, and to permit persons to whom the Software is
    // furnished to do so, subject to the following conditions:
    //
    // The above copyright notice and this permission notice shall be included in all
    // copies or substantial portions of the Software.
    //
    // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    // SOFTWARE.
    //
    import 'dart:ui';

    import 'package:flutter/gestures.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';

    void main() {
    runApp(
    MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData.dark(),
    home: const Home(),
    ),
    );
    }

    class Home extends StatelessWidget {
    const Home({super.key});

    @override
    Widget build(BuildContext context) {
    return Material(
    child: Stack(
    children: [
    ListView.builder(
    itemBuilder: (BuildContext context, int index) {
    return ListTile(
    title: Text('Item #$index'),
    );
    },
    ),
    const Positioned(
    left: 0.0,
    right: 0.0,
    bottom: 0.0,
    child: AnimatedBottomSheet(
    children: [
    SizedBox(
    height: 200.0,
    child: ColoredBox(
    color: Colors.tealAccent,
    child: Placeholder(),
    ),
    ),
    SizedBox(
    height: 400.0,
    child: ColoredBox(
    color: Colors.deepOrangeAccent,
    child: Placeholder(),
    ),
    ),
    SizedBox(
    height: 100.0,
    child: ColoredBox(
    color: Colors.purpleAccent,
    child: Placeholder(),
    ),
    ),
    ],
    ),
    ),
    ],
    ),
    );
    }
    }

    class AnimatedBottomSheet extends StatefulWidget {
    const AnimatedBottomSheet({
    super.key,
    this.initialPage = 0,
    this.alignment = Alignment.topCenter,
    required this.children,
    });

    final int initialPage;
    final Alignment alignment;
    final List<Widget> children;

    @override
    State<AnimatedBottomSheet> createState() => _AnimatedBottomSheetState();
    }

    class _AnimatedBottomSheetState extends State<AnimatedBottomSheet> {
    late final PageController _controller;

    @override
    void initState() {
    super.initState();
    _controller = PageController(
    initialPage: widget.initialPage,
    );
    }

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

    @override
    Widget build(BuildContext context) {
    return Material(
    color: Theme.of(context).cardColor,
    elevation: 8.0,
    shape: const RoundedRectangleBorder(
    borderRadius: BorderRadius.only(
    topLeft: Radius.circular(12.0),
    topRight: Radius.circular(12.0),
    ),
    ),
    clipBehavior: Clip.antiAlias,
    child: Scrollable(
    axisDirection: AxisDirection.right,
    physics: const PageScrollPhysics(),
    dragStartBehavior: DragStartBehavior.down,
    controller: _controller,
    viewportBuilder: (BuildContext context, ViewportOffset position) {
    return PartialHeightLayout(
    offset: position,
    alignment: widget.alignment,
    children: widget.children,
    );
    },
    ),
    );
    }
    }

    class PartialHeightLayout extends MultiChildRenderObjectWidget {
    const PartialHeightLayout({
    super.key,
    required this.alignment,
    required this.offset,
    required super.children,
    });

    final Alignment alignment;
    final ViewportOffset offset;

    @override
    RenderObject createRenderObject(BuildContext context) {
    return RenderPartialHeightLayout(
    alignment: alignment,
    offset: offset,
    );
    }

    @override
    void updateRenderObject(
    BuildContext context, RenderPartialHeightLayout renderObject) {
    renderObject //
    ..alignment = alignment
    ..offset = offset;
    }
    }

    class _PartialHeightLayoutParentData
    extends ContainerBoxParentData<RenderBox> {}

    class RenderPartialHeightLayout extends RenderBox
    with
    ContainerRenderObjectMixin<RenderBox, _PartialHeightLayoutParentData>,
    RenderBoxContainerDefaultsMixin<RenderBox,
    _PartialHeightLayoutParentData> {
    RenderPartialHeightLayout({
    required Alignment alignment,
    required ViewportOffset offset,
    }) : _alignment = alignment,
    _offset = offset;

    Alignment _alignment;

    Alignment get alignment => _alignment;

    set alignment(Alignment value) {
    if (_alignment == value) {
    return;
    }
    _alignment = value;
    markNeedsLayout();
    }

    ViewportOffset _offset;

    ViewportOffset get offset => _offset;

    set offset(ViewportOffset value) {
    if (_offset == value) {
    return;
    }
    // When updating the viewport offset, we might already
    // be attached and listening, stop listening to the old
    // offset and listen to the new one instead.
    if (attached) {
    _offset.removeListener(markNeedsLayout);
    }
    _offset = value;
    if (attached) {
    _offset.addListener(markNeedsLayout);
    }
    markNeedsLayout();
    }

    @override
    void attach(PipelineOwner owner) {
    super.attach(owner);
    // Only listen to Scrollable position when this layout is
    // attached to the render tree.
    _offset.addListener(markNeedsLayout);
    }

    @override
    void detach() {
    // Stop listening when we are detached from the render tree.
    _offset.removeListener(markNeedsLayout);
    super.detach();
    }

    @override
    void setupParentData(RenderBox child) {
    if (child.parentData is! _PartialHeightLayoutParentData) {
    child.parentData = _PartialHeightLayoutParentData();
    }
    }

    @override
    void performLayout() {
    final children = getChildrenAsList();

    // Determine which page of content we are within.
    final viewportWidth = constraints.maxWidth;
    final fractionalOffset =
    offset.hasPixels ? (offset.pixels / viewportWidth) : 0.0;

    // Layout all children from left to right and position them
    // based on the current scroll offset.
    double x = offset.hasPixels ? -offset.pixels : 0.0;
    for (int i = 0; i < children.length; i++) {
    final child = children[i];
    child.layout(constraints, parentUsesSize: true);
    final parentData = child.parentData as _PartialHeightLayoutParentData;
    parentData.offset = Offset(x, 0.0);
    x += child.size.width;
    }

    // Calculate the height of the layout based on what amount
    // of each child page is visible.
    int index = fractionalOffset.floor();
    double height;
    if (index >= 0 && index + 1 < children.length) {
    height = lerpDouble(
    children[index].size.height,
    children[index + 1].size.height,
    fractionalOffset - index,
    )!;
    } else {
    height = children[index].size.height;
    }

    final pageRect = Rect.fromLTWH(0, 0, viewportWidth, height);
    for (final child in children) {
    final parentData = child.parentData as _PartialHeightLayoutParentData;
    final aligned = alignment.inscribe(child.size, pageRect);
    parentData.offset = Offset(parentData.offset.dx, aligned.top);
    }

    // Report layout size to framework
    size = Size(
    viewportWidth,
    constraints.constrainHeight(height),
    );

    // Report content dimensions to Scrollable.
    offset.applyViewportDimension(viewportWidth);
    offset.applyContentDimensions(
    0.0,
    (x + offset.pixels) - viewportWidth,
    );
    }

    @override
    void paint(PaintingContext context, Offset offset) {
    // Paint all children in their expected positions
    defaultPaint(context, offset);
    }

    @override
    bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    // Hit-test all children based on their positions
    return defaultHitTestChildren(result, position: position);
    }
    }