Flutter - 在额外滚动条上显示行 - 列顶部(就像 Whatsapp 存档的聊天记录)

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

我想在一列之上放一行,一开始是不可见的。仅当 SingleChildScrollview 的滚动偏移量为负数时才会可见。

换句话说,只有当用户滚动得比正常情况(向下运动)更远时,才会显示此行。这是 Whatsapp 中的示例。 “搜索框”小部件最初不显示,仅在向上滚动时显示,向下滚动后消失。

更新

使用@Lulupointu 的回答,我得到了这个:

顶部小部件以流畅的动画滚动显示和隐藏。 @Hooshyar 的回答也有效,但不太流畅,并且使用了不同的方法。

尝试 SliverAppBar 并将 floatingsnap 命名参数设置为 true

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 Scaffold(
      body: CustomScrollView(
        slivers: <Widget>[
        const  SliverAppBar(
            pinned: true,
            snap: true,
            floating: true,
            expandedHeight: 160.0,
            flexibleSpace: FlexibleSpaceBar(
              title:  Text('SliverAppBar'),
              background: FlutterLogo(),
            ),
          ),
          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,
            ),
          ),
        ],
      ),
    );
  }
}

这里使用 singleChildScrollView 是我使用 s NotificationListener() 小部件想到的,还有其他解决方案,但这个是最简单的:

有一个布尔值来确定容器可见性:

  bool shouldIShowTheUpperThing = false;

用 NotificationListener() 包装您的 SingleChildScrollView() :

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;
        });
  }

由于这是一个不常见的滚动效果,如果你想要它看起来不错,我认为你需要使用条子。

实施

我所做的是复制粘贴SliverToBoxAdapter并修改它。

特点:

  • 首次加载小部件时 child 隐藏
  • child活泼
  • 当用户再次向下滚动时child隐藏

限制:

  • 如果CustomScrollView到over-scroll的children不够,child会一直可见,启动时会出现奇怪的滚动效果
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);
  }
}

如何使用

在你的条子列表的顶部使用它:

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,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

其他资源

如果您想用不同的方法解决这个问题,请查看 pull_to_refresh 包。请注意,他们的代码非常复杂,因为他们还实现了 auto-hide 功能,但如果您有时间,值得一看。

解决限制

我不确定使用这种方法可以解决限制。问题是,出于性能原因,sliver 对它下面的那个一无所知,这意味着它甚至很难知道我们何时处于有问题的情况下,更不用说处理它了。