Search code examples
fluttersticky

Position subwidget always on top of VISIBLE part of parent


I have following layout:

Scaffold which contains a fixed Appbar and a SingleChildScrollView. The ScrollView usually contains Column with some kind of content (I call it ContentWidget) followed by several sections I call OutputWidgets.

Maybe it matters, so: each OutputWidget consists of a Row with - left - an Expanded with some kind of textual output and - right - a small container at the top right corner (which contains a Column with several buttons):

OutputWidget

Now I want achieve, what sometimes is done on websites: I want the button bar to stay visible as long as possible during scrolling:

enter image description here

  1. First OutputWidget is completely visible, so the button bar ("sticky") widget is positioned at the top right; Second OutputWidget is only partly visible but its button bar is also positioned at the top right corner.
  2. When the entire Scaffold body is scrolled up, the first OutputWidget is hidden partly. But I want the buttons stick at the Scaffold's app header as long as still visible part of the OutputWidget is big enough.
  3. If the visible part becomes too small (to few height space) then the sticky widget is located at the OutputWidget's bottom and gets also partly hidden. Meanwhile the second OutputWidget's sticky widget scrolls up with the scrollbar and keeps sticky at the top right corner.
  4. The first OutputWidget is completely hidden (at least under the Scaffold's app bar), so its sticky widget is, too. The second OutputWidget's button bar is now doing the same and stays at the app bar until the space is not enough anymore.

That way I ensure, that the OutputWidgets' button bars keep visible as long as possible.

Long story short:

In my opinion, I need a way to get the position of direct children of a SingleChildScrollView and the current visible offset. In that case I could use a GestureDetector at the ScrollView to do something like:

If (child top < visible top) 
    stickyWidget.top = child top - visible top

So, if my idea is correct:

  1. How can the ScrollViews child get its position in relation of the parent ScrollViews top?
  2. How does it know the visible top of the ScrollView? (maybe if 100px already scrolled up, then the visible position is 100px, right?)?

Or is there a completely different way?

Do you have an idea how to do this? Thanks in advance!

Edit:

I believe, this is a nice approach: https://pub.dev/packages/flutter_sticky_header

But I don't need a full header, just a side container. Nonetheless, the behaviour is quite similar.


Solution

  • This seems like an excelent use case for Slivers, because each widget needs to be aware of its constraints inside the scroll view.

    A solution might be achievable using a package like flutter_sticky_header or sliver_tools but it might not be trivial because these packages help you create pinned slivers that take their own space inside the scroll view instead of being stacked on the item.

    So, let's implement our own widget: SliverContentWithStickyOverlay, not sure if it's the best name but naming things is hard.

    Since we need our widget to have two slots, content and overlay, I'm thinking that we can use SlottedMultiChildRenderObjectWidget as the base class. There's a nice example in the documentation of this class but not quite what we need, because the render object there is a box (extends RenderBox) and we need a sliver (extends RenderSliver).

    The relevant classes bellow are:

    • SliverContentWithStickyOverlay - our special sliver widget
    • SliverContentWithStickyOverlaySlot - defines the slots of the widget
    • RenderSliverContentWithStickyOverlay - extends RenderSliver to achieve the effect

    Preview:

    Preview

    DartPad:

    https://dartpad.dev/?id=46ff491cb3ba82c78fdabec6c9707402

    Code:

    import 'dart:math';
    
    import 'package:flutter/foundation.dart';
    import 'package:flutter/rendering.dart';
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: Center(
              child: SizedBox(
                // We limit the width of the content intentionally to force the slivers take more space vertically.
                // Otherwise, on very wide screens the result might not be visible.
                width: 320,
                child: const MainPage(),
              ),
            ),
          ),
        );
      }
    }
    
    class MainPage extends StatelessWidget {
      const MainPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('Main Page'),
          ),
          body: SafeArea(
            child: CustomScrollView(
              slivers: [
                SliverContentWithStickyOverlay(
                  content: Container(
                    color: const Color(0xFF002B7F).withValues(alpha: 0.5),
                    child: Padding(
                      padding: const EdgeInsets.fromLTRB(16, 16, 54, 16),
                      child: Text(
                        'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
                        textAlign: TextAlign.justify,
                      ),
                    ),
                  ),
                  overlay: SideButtonsWidget(),
                ),
                SliverContentWithStickyOverlay(
                  content: Container(
                    color: const Color(0xFFFCD116).withValues(alpha: 0.5),
                    child: Padding(
                      padding: const EdgeInsets.fromLTRB(16, 16, 54, 16),
                      child: Text(
                        'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?',
                        textAlign: TextAlign.justify,
                      ),
                    ),
                  ),
                  overlay: SideButtonsWidget(),
                ),
                SliverContentWithStickyOverlay(
                  content: Container(
                    color: const Color(0xFFCE1126).withValues(alpha: 0.5),
                    child: Padding(
                      padding: const EdgeInsets.fromLTRB(16, 16, 54, 16),
                      child: Text(
                        'At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.',
                        textAlign: TextAlign.justify,
                      ),
                    ),
                  ),
                  overlay: SideButtonsWidget(),
                ),
                SliverToBoxAdapter(
                  child: Container(
                    color: Colors.grey.withValues(alpha: 0.5),
                    height: 800,
                    child: Center(
                      child: Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Text(
                          'Some space used to demonstrate how the slivers behave on scroll',
                          textAlign: TextAlign.center,
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      }
    }
    
    class SideButtonsWidget extends StatelessWidget {
      const SideButtonsWidget({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 0),
              child: Column(
                children: [
                  IconButton(
                    onPressed: () {
                      print('Share pressed');
                    },
                    icon: Icon(Icons.share),
                    color: Colors.blue.shade900,
                  ),
                  IconButton(
                    onPressed: () {
                      print('Favorite pressed');
                    },
                    icon: Icon(Icons.favorite),
                    color: Colors.red.shade900,
                  ),
                ],
              ),
            )
          ],
        );
      }
    }
    
    enum SliverContentWithStickyOverlaySlot {
      content,
      overlay,
    }
    
    class SliverContentWithStickyOverlay
        extends SlottedMultiChildRenderObjectWidget<
            SliverContentWithStickyOverlaySlot, RenderBox> {
      final Widget content;
      final Widget overlay;
    
      const SliverContentWithStickyOverlay({
        super.key,
        required this.content,
        required this.overlay,
      });
    
      @override
      Iterable<SliverContentWithStickyOverlaySlot> get slots =>
          SliverContentWithStickyOverlaySlot.values;
    
      @override
      Widget? childForSlot(SliverContentWithStickyOverlaySlot slot) {
        switch (slot) {
          case SliverContentWithStickyOverlaySlot.content:
            return content;
          case SliverContentWithStickyOverlaySlot.overlay:
            return overlay;
        }
      }
    
      @override
      SlottedContainerRenderObjectMixin<SliverContentWithStickyOverlaySlot,
          RenderBox> createRenderObject(
        BuildContext context,
      ) {
        return RenderSliverContentWithStickyOverlay();
      }
    }
    
    class RenderSliverContentWithStickyOverlay extends RenderSliver
        with
            SlottedContainerRenderObjectMixin<SliverContentWithStickyOverlaySlot, RenderBox>,
            RenderSliverHelpers {
      // Constructor
    
      RenderSliverContentWithStickyOverlay();
    
      RenderBox? get _contentRenderBox => childForSlot(SliverContentWithStickyOverlaySlot.content);
      RenderBox? get _overlayRenderBox => childForSlot(SliverContentWithStickyOverlaySlot.overlay);
    
      @override
      Iterable<RenderBox> get children sync* {
        if (_contentRenderBox != null) yield _contentRenderBox!;
        if (_overlayRenderBox != null) yield _overlayRenderBox!;
      }
    
      @override
      void performLayout() {
        final boxConstraints = constraints.asBoxConstraints();
    
        Size contentSize = Size.zero;
        final contentRenderBox = _contentRenderBox;
        if (contentRenderBox != null) {
          contentRenderBox.layout(boxConstraints, parentUsesSize: true);
          contentSize = contentRenderBox.size;
        }
    
        Size overlaySize = Size.zero;
        final overlayRenderBox = _overlayRenderBox;
        if (overlayRenderBox != null) {
          overlayRenderBox.layout(boxConstraints, parentUsesSize: true);
          overlaySize = overlayRenderBox.size;
        }
    
        final size = Size(
          max(contentSize.width, overlaySize.width),
          max(contentSize.height, overlaySize.height),
        );
    
        final childrenExtent = switch (constraints.axis) {
          Axis.horizontal => size.width,
          Axis.vertical => size.height,
        };
    
        final paintExtent = calculatePaintExtent(constraints, from: 0.0, to: childrenExtent);
        final cacheExtent = calculateCacheExtent(constraints, from: 0.0, to: childrenExtent);
    
        geometry = SliverGeometry(
          scrollExtent: childrenExtent,
          paintExtent: paintExtent,
          cacheExtent: cacheExtent,
          maxPaintExtent: childrenExtent,
          hitTestExtent: paintExtent,
          hasVisualOverflow:
              childrenExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
        );
      }
    
      @override
      void paint(PaintingContext context, Offset offset) {
        if (_contentRenderBox != null) {
          final paintOffset = switch (constraints.axis) {
            Axis.horizontal => offset.translate(-constraints.scrollOffset, 0),
            Axis.vertical => offset.translate(0, -constraints.scrollOffset),
          };
    
          context.paintChild(
            _contentRenderBox!,
            paintOffset,
          );
        }
    
        if (_overlayRenderBox != null) {
          final paintOffset = switch (constraints.axis) {
            Axis.horizontal => offset.translate(
                min(
                    0,
                    geometry!.maxPaintExtent -
                        _overlayRenderBox!.size.width -
                        constraints.scrollOffset),
                0,
              ),
            Axis.vertical => offset.translate(
                0,
                min(
                    0,
                    geometry!.maxPaintExtent -
                        _overlayRenderBox!.size.height -
                        constraints.scrollOffset),
              ),
          };
          context.paintChild(
            _overlayRenderBox!,
            paintOffset,
          );
        }
      }
    
      @override
      double childMainAxisPosition(covariant RenderObject child) {
        if (child != _overlayRenderBox) {
          return 0;
        }
    
        final box = _overlayRenderBox!;
    
        // INFO: up/right/left cases are not tested
        return switch (constraints.axisDirection) {
          AxisDirection.down => min(0, geometry!.layoutExtent - box.size.height),
          AxisDirection.up => max(0, geometry!.layoutExtent - box.size.height),
          AxisDirection.right => min(0, geometry!.layoutExtent - box.size.width),
          AxisDirection.left => max(0, geometry!.layoutExtent - box.size.width),
        };
      }
    
      @override
      bool hitTestChildren(
        SliverHitTestResult result, {
        required double mainAxisPosition,
        required double crossAxisPosition,
      }) {
        final boxHitTestResult = BoxHitTestResult.wrap(result);
    
        if (_overlayRenderBox != null) {
          if (hitTestBoxChild(boxHitTestResult, _overlayRenderBox!,
              mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) {
            return true;
          }
        }
    
        if (_contentRenderBox != null) {
          if (hitTestBoxChild(boxHitTestResult, _contentRenderBox!,
              mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) {
            return true;
          }
        }
    
        return false;
      }
    
      @override
      void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {
        if (child is RenderBox) {
          applyPaintTransformForBoxChild(child, transform);
        }
      }
    
      double calculatePaintExtent(SliverConstraints constraints,
          {required double from, required double to}) {
        assert(from <= to);
        final double a = constraints.scrollOffset;
        final double b = constraints.scrollOffset + constraints.remainingPaintExtent;
        // the clamp on the next line is to avoid floating point rounding errors
        return clampDouble(
            clampDouble(to, a, b) - clampDouble(from, a, b), 0.0, constraints.remainingPaintExtent);
      }
    
      double calculateCacheExtent(SliverConstraints constraints,
          {required double from, required double to}) {
        assert(from <= to);
        final double a = constraints.scrollOffset + constraints.cacheOrigin;
        final double b = constraints.scrollOffset + constraints.remainingCacheExtent;
        // the clamp on the next line is to avoid floating point rounding errors
        return clampDouble(
            clampDouble(to, a, b) - clampDouble(from, a, b), 0.0, constraints.remainingCacheExtent);
      }
    }
    
    

    The heavy lifting is done in the RenderSliverContentWithStickyOverlay in the overrides of the methods:

    • performLayout
    • paint
    • hitTestChildren

    The Sliver API is not very simple so, some math in here is obtained through trial and error. In short, for the layout part you have some constraints provided to you and based on them you can set the geometry of the Sliver. Then for paint you can use the layout and the geometry to paint the children. Then we have to handle hit tests to support gestures for content and overlay.

    Please note that in this widget, both the content and the overlay will attempt to fill the crossAxis dimension, so it's your responsibility to set some constraints to avoid overlapping. For example, in a vertical scroll view, the overlay will behave like a sticky header, it will take the full width, but it will not create its own vertical extent (it will stay stacked over the content).