Reveal header on pull and pin until scroll + pull to refresh

80 views Asked by At

I implemented the following header reveal on pull in Flutter but it does not work properly and I want to add more advanced features.

test

The subheader should stick to the top of the list once it is fully revealed. And it should get unpinned once the user scrolls it a bit back up.

My problem is that I also want to support the pull to refresh once the user pulls even more (the pull to refresh should be even above the subheader).

I tried the following, but it obviously does not work for many reasons:

  • the subheader is not part of the scroll container and hence
    • the subheader does not stick to the items when wanting to do pull to refresh and
    • the user cannot "move it up to hide it again" unless scrolling on the list (but should be able to do the gesture on the subheader itself)
  • Later I want to have a red dot in the AppBar that should become the subheader by animating it, which probably requires a CustomScroll approach together with some CustomPainter or so rather than my Stack + ListView stuff etc?
class RevealSubheaderPage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    const double HEIGHT = 96;
    final pinned = useState(false);
    final extend = useState(0.0);
    return Scaffold(
      appBar: AppBar(title: const Text('Test')),
      body: Stack(
        children: [
          NotificationListener<ScrollNotification>(
            onNotification: (n) {
              if (pinned.value) {
                print(n.metrics.pixels);
                if (n.metrics.pixels > 0 && n.metrics.pixels < HEIGHT) {
                  extend.value = n.metrics.pixels;
                }
              } else {
                if (n.metrics.pixels < 0) {
                  extend.value = n.metrics.pixels;
                }
                if (n.metrics.pixels < -HEIGHT) {
                  pinned.value = true;
                  extend.value = -HEIGHT;
                }
              }
              return false;
            },
            child: ListView.builder(
              itemCount: 42,
              itemBuilder: (context, index) {
                return Container(
                  color: Colors.green,
                  padding: const EdgeInsets.all(16),
                  alignment: Alignment.centerLeft,
                  child: Text('Item $index'),
                );
              },
            ),
          ),
          HideableWidget(height: HEIGHT, notifier: extend),
        ],
      ),
    );
  }
}

class HideableWidget extends StatelessWidget {
  final double height;
  final ValueNotifier<double> notifier;

  HideableWidget({required this.height, required this.notifier});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<double>(
      valueListenable: notifier,
      builder: (context, value, child) {
        return Transform.translate(
          offset: Offset(0, -height - value),
          child: Container(
            height: height,
            width: double.infinity,
            color: Colors.red,
            alignment: Alignment.bottomCenter,
            child: Text("Subheader"),
          ),
        );
      },
    );
  }
}

Update

I was able to get it almost done using CustomScrollView, but as you can see I moved the revealed image in the vertical center. When I make it stick to the top/bottom I struggle with calculating the BouncingScrollPhysics offset so the widget would have a heigh of the total available space? By moving it to the middle it look ok-ish, but I would love to occupy the complete space. Also I was not able to integrate RefreshIndicator which should occure even above the subheader (so sould take another 100px of overscrolling before it starts being faded in).

test
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class Test3 extends StatelessWidget {

  const Test3({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: RevealSubheaderPage(
          revealHeight: 100,
          revealSnapHeight: 80,
        ),
      ),
    );
  }
}

class RevealSubheaderPage extends HookWidget {
  final double revealHeight;1
  final double revealSnapHeight;

  const RevealSubheaderPage({
    super.key,
    required this.revealHeight,
    required this.revealSnapHeight,
  });

  @override
  Widget build(BuildContext context) {
    final snap = useState(false);
    final scrollExtend = useState(0.0);
    final scrollController = useScrollController();
    useEffect(() {
      l() {
        final o = scrollController.offset;
        scrollExtend.value = o;
        if (!snap.value && o < -revealSnapHeight) {
          snap.value = true;
        } else {
          if (snap.value && o > 0) {
            snap.value = false;
          }
        }
      }

      scrollController.addListener(l);
      return () => scrollController.removeListener(l);
    });

    return CustomScrollView(
      controller: scrollController,
      slivers: [
        const SliverAppBar(
          title: Text("Some Title"),
          pinned: true,
        ),
        SliverToBoxAdapter(
          child: Transform.translate(
            offset: Offset(
                0,
                snap.value
                    ? scrollExtend.value < 0
                        ? scrollExtend.value / 2
                        : 0
                    : scrollExtend.value < 0
                        ? scrollExtend.value / 2
                        : 0),
            child: AnimatedContainer(
              duration: const Duration(milliseconds: 150),
              height: snap.value
                  ? revealHeight
                  : scrollExtend.value < 0
                      ? -scrollExtend.value
                      : 0,
              child: Container(
                height: revealHeight,
                width: double.infinity,
                color: Colors.red,
                alignment: Alignment.centerLeft,
                child: Text("Subheader"),
              ),
            ),
          ),
        ),
        SliverList(
          delegate: SliverChildBuilderDelegate(
            childCount: 42,
            (context, index) {
              return Container(
                color: Colors.green,
                padding: const EdgeInsets.all(16),
                alignment: Alignment.centerLeft,
                child: Text('Item $index'),
              );
            },
          ),
        ),
      ],
    );
  }
}
0

There are 0 answers