Flutter - Show Row on extra scroll - Top of column (Like Whatsapp Archived Chats)

328

Solution 1

Since this is a unusual scroll effect, if you want it to look good I think you need to use slivers.

Implementation

What I did was copy paste SliverToBoxAdapter and modify it.

Features:

  • The child is hidden when the widget is first loaded
  • The child is snappy
  • The child hide when the user scroll down again

Limitations:

  • If there are not enough children in the CustomScrollView to over-scroll, the child will always be visible and a weird scroll effect will appear on startup
class SliverHidedHeader extends SingleChildRenderObjectWidget {
  const SliverHidedHeader({
    Key? key,
    required Widget child,
  }) : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverHidedHeader(context: context);
  }
}

class RenderSliverHidedHeader extends RenderSliverSingleBoxAdapter {
  RenderSliverHidedHeader({
    required BuildContext context,
    RenderBox? child,
  })  : _context = context,
        super(child: child);

  /// Whether we need to apply a correction to the scroll
  /// offset during the next layout
  ///
  ///
  /// This is useful to avoid the viewport to jump when we
  /// insert/remove the child.
  ///
  /// If [showChild] is true, its an insert
  /// If [showChild] is false, its a removal
  bool _correctScrollOffsetNextLayout = true;

  /// Whether [child] should be shown
  ///
  ///
  /// This is used to hide the child when the user scrolls down
  bool _showChild = true;

  /// The context is used to get the [Scrollable]
  BuildContext _context;

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    final SliverConstraints constraints = this.constraints;
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    final double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }
    final double paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);
    final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    assert(paintedChildSize.isFinite);
    assert(paintedChildSize >= 0.0);

    // Here are the few custom lines, which use [scrollOffsetCorrection]
    // to remove the child size
    //
    // Note that this should only be called for correction linked with the
    // insertion (NOT the removal)
    if (_correctScrollOffsetNextLayout) {
      geometry = SliverGeometry(scrollOffsetCorrection: childExtent);
      _correctScrollOffsetNextLayout = false;
      return;
    }

    // Subscribe a listener to the scroll notifier
    // which will snap if needed
    _manageSnapEffect(
      childExtent: childExtent,
      paintedChildSize: paintedChildSize,
    );

    // Subscribe a listener to the scroll notifier
    // which hide the child if needed
    _manageInsertChild(
      childExtent: childExtent,
      paintedChildSize: paintedChildSize,
    );

    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintExtent: paintedChildSize,
      paintOrigin: _showChild ? 0 : -paintedChildSize,
      layoutExtent: _showChild ? null : 0,
      cacheExtent: cacheExtent,
      maxPaintExtent: childExtent,
      hitTestExtent: paintedChildSize,
      hasVisualOverflow:
          childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
    );
    setChildParentData(child!, constraints, geometry!);
  }

  /// Override to remove the listeners if needed
  @override
  void dispose() {
    final _scrollPosition = Scrollable.of(_context)!.position;
    if (_subscribedSnapScrollNotifierListener != null) {
      _scrollPosition.isScrollingNotifier
          .removeListener(_subscribedSnapScrollNotifierListener!);
    }
    if (_subscribedInsertChildScrollNotifierListener != null) {
      _scrollPosition.isScrollingNotifier
          .removeListener(_subscribedInsertChildScrollNotifierListener!);
    }

    super.dispose();
  }

  /// The listener which will snap if needed
  ///
  ///
  /// We store it to be able to remove it before subscribing
  /// a new one
  void Function()? _subscribedSnapScrollNotifierListener;

  /// Handles the subscription and removal of subscription to
  /// the scrollable position notifier which are responsible
  /// for the snapping effect
  ///
  ///
  /// This must be called at each [performLayout] to ensure that the
  /// [childExtent] and [paintedChildSize] parameters are up to date
  _manageSnapEffect({
    required double childExtent,
    required double paintedChildSize,
  }) {
    final _scrollPosition = Scrollable.of(_context)!.position;

    // If we were subscribed with previous value, remove the subscription
    if (_subscribedSnapScrollNotifierListener != null) {
      _scrollPosition.isScrollingNotifier
          .removeListener(_subscribedSnapScrollNotifierListener!);
    }

    // We store the subscription to be able to remove it
    _subscribedSnapScrollNotifierListener = () => _snapScrollNotifierListener(
          childExtent: childExtent,
          paintedChildSize: paintedChildSize,
        );
    _scrollPosition.isScrollingNotifier.addListener(_subscribedSnapScrollNotifierListener!);
  }

  /// Snaps if the user just stopped scrolling and the child is
  /// partially visible
  void _snapScrollNotifierListener({
    required double childExtent,
    required double paintedChildSize,
  }) {
    final _scrollPosition = Scrollable.of(_context)!.position;

    // Whether the user is currently idle (i.e not scrolling)
    //
    // We don't check _scrollPosition.activity.isScrolling or
    // _scrollPosition.isScrollingNotifier.value because even if
    // the user is holding still we don't want to start animating
    //
    // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
    final isIdle = _scrollPosition.activity is IdleScrollActivity;

    // Whether at least part of the child is visible
    final isChildVisible = paintedChildSize > 0;

    if (isIdle && isChildVisible) {
      // If more than half is visible, snap to see everything
      if (paintedChildSize >= childExtent / 2 && paintedChildSize != childExtent) {
        _scrollPosition.animateTo(
          0,
          duration: Duration(milliseconds: 100),
          curve: Curves.easeOut,
        );
      }

      // If less than half is visible, snap to hide
      else if (paintedChildSize < childExtent / 2 && paintedChildSize != 0) {
        _scrollPosition.animateTo(
          childExtent,
          duration: Duration(milliseconds: 200),
          curve: Curves.easeOut,
        );
      }
    }
  }

  /// The listener which will hide the child if needed
  ///
  ///
  /// We store it to be able to remove it before subscribing
  /// a new one
  void Function()? _subscribedInsertChildScrollNotifierListener;

  /// Handles the subscription and removal of subscription to
  /// the scrollable position notifier which are responsible
  /// for inserting/removing the child if needed
  ///
  ///
  /// This must be called at each [performLayout] to ensure that the
  /// [childExtent] and [paintedChildSize] parameters are up to date
  void _manageInsertChild({
    required double childExtent,
    required double paintedChildSize,
  }) {
    final _scrollPosition = Scrollable.of(_context)!.position;

    // If we were subscribed with previous value, remove the subscription
    if (_subscribedInsertChildScrollNotifierListener != null) {
      _scrollPosition.isScrollingNotifier
          .removeListener(_subscribedInsertChildScrollNotifierListener!);
    }

    // We store the subscription to be able to remove it
    _subscribedInsertChildScrollNotifierListener = () => _insertChildScrollNotifierListener(
          childExtent: childExtent,
          paintedChildSize: paintedChildSize,
        );
    _scrollPosition.isScrollingNotifier
        .addListener(_subscribedInsertChildScrollNotifierListener!);
  }

  /// When [ScrollPosition.isScrollingNotifier] fires:
  ///   - If the viewport is at the top and the child is not visible,
  ///   ^ insert the child
  ///   - If the viewport is NOT at the top and the child is NOT visible,
  ///   ^ remove the child
  void _insertChildScrollNotifierListener({
    required double childExtent,
    required double paintedChildSize,
  }) {
    final _scrollPosition = Scrollable.of(_context)!.position;

    final isScrolling = _scrollPosition.isScrollingNotifier.value;

    // If the user is still scrolling, do nothing
    if (isScrolling) {
      return;
    }

    final scrollOffset = _scrollPosition.pixels;

    // If the viewport is at the top and the child is not visible,
    // insert the child
    //
    // We use 0.1 as a small value in case the user is nearly scrolled
    // all the way up
    if (!_showChild && scrollOffset <= 0.1) {
      _showChild = true;
      _correctScrollOffsetNextLayout = true;
      markNeedsLayout();
    }

    // There is sometimes an issue with [ClampingScrollPhysics] where
    // the child is NOT shown but the scroll offset still includes [childExtent]
    //
    // There is no why to detect it but we always insert the child when all
    // this conditions are united.
    // This means that if a user as [ClampingScrollPhysics] and stops scrolling
    // exactly at [childExtent], the child will be wrongfully inserted. However
    // this seems a small price to pay to avoid the issue.
    if (_scrollPosition.physics.containsScrollPhysicsOfType<ClampingScrollPhysics>()) {
      if (!_showChild && scrollOffset == childExtent) {
        _showChild = true;
        markNeedsLayout();
      }
    }

    // If the viewport is NOT at the top and the child is NOT visible,
    // remove the child
    if (_showChild && scrollOffset > childExtent) {
      _showChild = false;
      markNeedsLayout();

      // We don't have to correct the scroll offset here, no idea why
    }
  }
}

/// An extension on [ScrollPhysics] to check if it or its
/// parent are the given [ScrollPhysics]
extension _ScrollPhysicsExtension on ScrollPhysics {
  /// Check the type of this [ScrollPhysics] and its parents and return
  /// true if any is of type [T]
  bool containsScrollPhysicsOfType<T extends ScrollPhysics>() {
    return this is T || (parent?.containsScrollPhysicsOfType<T>() ?? false);
  }
}

How to use it

Use it at the top of your list of slivers:

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

void main() {
  runApp(MaterialApp(home: MyStatefulWidget()));
}

class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({Key? key}) : super(key: key);

  @override
  State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        body: CustomScrollView(
          slivers: <Widget>[
            SliverHidedHeader(
              child: Container(
                child: Center(child: Text('SliverAppBar')),
                height: 100,
                color: Colors.redAccent,
              ),
            ),
            const SliverToBoxAdapter(
              child: SizedBox(
                height: 20,
                child: Center(
                  child: Text('Scroll to see the SliverAppBar in effect.'),
                ),
              ),
            ),
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (BuildContext context, int index) {
                  return Container(
                    color: index.isOdd ? Colors.white : Colors.black12,
                    height: 100.0,
                    child: Center(
                      child: Text('$index', textScaleFactor: 5),
                    ),
                  );
                },
                childCount: 20,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Other resource

Check out the pull_to_refresh package if you want a different approach to solve this issue. Beware that their code is quite complex since they also have the auto-hide feature implemented, but it's worth a look if you have time.

Solve the limitation

I'm not sure the limitation can be solved using this approach. The issue is that, for performance reason, the sliver does not know anything about the one bellow it, meaning that it's quite hard to even knowing when we are in the problematic case, let alone handle it.

Solution 2

Using singleChildScrollView here is what I came up with using s NotificationListener() widget, there are other solutions but this one is the simplest one:

have a bool to determine Container visibility:

  bool shouldIShowTheUpperThing = false;

the have your SingleChildScrollView() wrapped with a NotificationListener() :

NotificationListener(
        child: SingleChildScrollView(
          child: Column(
            children: [
              shouldIShowTheUpperThing == false ? Row(
    children: [
      Container(height: 0,),
    ],
    ) :   Row(
    children: [
      Expanded(child: Container(color: Colors.red , height: 100 , child: Text('the hidden box'),)),
    ],
    ),
              Container(
                padding: EdgeInsets.all(130),
                child: Text('data'),
                color: Colors.blueGrey,
              ),
              Container(
                padding: EdgeInsets.all(130),
                child: Text('data'),
                color: Colors.blueAccent,
              ),
              Container(
                padding: EdgeInsets.all(130),
                child: Text('data'),
                color: Colors.amber,
              ),
              Container(
                padding: EdgeInsets.all(130),
                child: Text('data'),
                color: Colors.black12,
              ),
              Container(
                padding: EdgeInsets.all(130),
                child: Text('data'),
              ),
            ],
          ),
        ),
        
        onNotification: (t) {
          if (t is ScrollNotification) {
            if (t.metrics.pixels < 1.0) {
              setState(() {
                shouldIShowTheUpperThing = true;
              });
            }
          }
          return true;
        });
  }
Share:
328
Tomas Ward
Author by

Tomas Ward

I love programming.

Updated on January 01, 2023

Comments

  • Tomas Ward
    Tomas Ward over 1 year

    I want to put a row on top of a column, which will not be visible initially. It will only be visible when the scroll offset of the SingleChildScrollview is negative.

    In other words, only if the user scrolls further than normal (downwards motion) will this Row show. This is an example in Whatsapp. The "Search Box" widget is not shown initially, only if you scroll up, and disappears once scroll is downwards.

    enter image description here

    UPDATE

    Using @Lulupointu's answer, I was able to get to this: enter image description here

    The top widget shows and hides on scroll with a smooth animation. @Hooshyar's answer also works but is less smooth and uses a different method.