5

I'm trying to find a way to implement a functionality in which, in a horizontally scrollable list, there are widgets that I will call P, (which are denoted as P1, P2 and P3 in the diagram) and their children C, (which are denoted as C1, C2 and C3). As the user scrolls the list horizontally, I want C's inside P's to act like sticky headers, until they reach the boundary of their parent.

I'm sorry if the description & diagram is not enough, I will try to clarify anything unclear.

Diagram of the problem

As I'm thinking of a way to implement this, I can't seem to find a plausible solution. Also if there is a package that can help with this issue, I would really appreciate any suggestions.

Baran S.
  • 53
  • 3
  • The question isn't too clear to me. Can you please try to explain a bit more? – MendelG Dec 28 '22 at 23:05
  • You want to create a horizontal sticky header? – MendelG Dec 28 '22 at 23:11
  • @MendelG It is in fact a horizontal sticky header but, the sticky header is internal to the scrolled content (P's) not P's themselves are meant to be sticky headers, and once the P's go out of screen or the extents of P end in a way that sticky acting child C cannot follow the scroll anymore. Sorry if I'm still being vague, in case you need more clarification, I will try my best. – Baran S. Dec 29 '22 at 12:03

2 Answers2

4

I am not sure about your picture, but maybe this is do you want?

enter image description here

enter image description here

our tools :

  1. BuildOwners -> to measure size of the widget before rebuild,
  2. NotificationListeners -> to trigger rebuild based on ScrollNotification. i use stateful Widget, but you can tweak it into ValueNotifier and Build the Sticker with ValueListenableBuilder instead.
  3. ListView.Builder -> actually you can replace this with any kind of Scrollable, we only need to listen scroll event.

how its work?

its simple : we need to know the P dx Offset, check if C offset small than P, then use that value to adjust x Positioned of C in Stack. and clamp it with max value (P.width)

double _calculateStickerXPosition(
      {required double px, required double cx, required double cw}) {
    if (cx < px) {
      return widget.stickerHorizontalPadding + (px - cx).clamp(0.0, cw - (widget.stickerHorizontalPadding*2));
    }

    return widget.stickerHorizontalPadding;
  }

full code :

main.dart :

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

import 'scrollable_sticker.dart';




void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return  MaterialApp(
      // i use chrome to test it, so igrone this
      scrollBehavior: const MaterialScrollBehavior().copyWith(
        dragDevices: {
          PointerDeviceKind.mouse,
          PointerDeviceKind.touch,
          PointerDeviceKind.stylus,
          PointerDeviceKind.unknown
        },
      ),
      home: const MyWidget(),
    );
  }
}

class MyWidget extends StatelessWidget {
  const MyWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.symmetric(vertical: 20.0),
        child: ScrollableSticker(
            children: List.generate(10, (index) => Container(
              width: 500,
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(10.0),
                  border: Border.all(color: Colors.orange)),
              child: const Padding(
                padding: EdgeInsets.symmetric(vertical: 50.0, horizontal: 50.0),
                child: Text(
                  "P1",
                  textDirection: TextDirection.ltr,
                ),
              ),
            )),
            stickerBuilder: (index) => Container(
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(10), color: Colors.red),
              child: Padding(
                padding: const EdgeInsets.all(10.0),
                child: Text(
                  'C$index',
                ),
              ),
            )),
      ),
    );
  }
}

scrollable_sticker.dart :

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

class ScrollableSticker extends StatefulWidget {
  final List<Widget> children;
  final Widget Function(int index) stickerBuilder;
  final double stickerHorizontalPadding;
  const ScrollableSticker(
      {Key? key,
      required this.children,
      required this.stickerBuilder,
      this.stickerHorizontalPadding = 10.0})
      : super(key: key);

  @override
  State<ScrollableSticker> createState() => _ScrollableStickerState();
}

class _ScrollableStickerState extends State<ScrollableSticker> {
  late List<GlobalKey> _keys;
  late GlobalKey _parentKey;

  @override
  void initState() {
    super.initState();
    _keys = List.generate(widget.children.length, (index) => GlobalKey());
    _parentKey = GlobalKey();
  }

  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollNotification>(
      onNotification: (sc) {
        setState(() {});
        return true;
      },
      child: ListView.builder(
        key: _parentKey,
        scrollDirection: Axis.horizontal,
        itemCount: widget.children.length,
        itemBuilder: (context, index) {
          final itemSize = measureWidget(Directionality(
              textDirection: TextDirection.ltr, child: widget.children[index]));
          final stickerSize = measureWidget(Directionality(
              textDirection: TextDirection.ltr,
              child: widget.stickerBuilder(index)));
          final BuildContext? itemContext = _keys[index].currentContext;
          double x = widget.stickerHorizontalPadding;
          if (itemContext != null) {
            final pcontext = _parentKey.currentContext;
            Offset? pOffset;
            if (pcontext != null) {
              RenderObject? obj = pcontext.findRenderObject();
              if (obj != null) {
                final prb = obj as RenderBox;
                pOffset = prb.localToGlobal(Offset.zero);
              }
            }
            final obj = itemContext.findRenderObject();
            if (obj != null) {
              final rb = obj as RenderBox;
              final cx = rb.localToGlobal(pOffset ?? Offset.zero).dx;
              x = _calculateStickerXPosition(
                  px: pOffset != null ? pOffset.dx : 0.0,
                  cx: cx,
                  cw: (itemSize.width - stickerSize.width));
            }
          }
          return SizedBox(
            key: _keys[index],
            height: itemSize.height,
            width: itemSize.width,
            child: Stack(
              children: [
                widget.children[index],
                Positioned(
                    top: itemSize.height / 2,
                    left: x,
                    child: FractionalTranslation(
                        translation: const Offset(0.0, -0.5),
                        child: widget.stickerBuilder(index)))
              ],
            ),
          );
        },
      ),
    );
  }

  double _calculateStickerXPosition(
      {required double px, required double cx, required double cw}) {
    if (cx < px) {
      return widget.stickerHorizontalPadding +
          (px - cx).clamp(0.0, cw - (widget.stickerHorizontalPadding * 2));
    }

    return widget.stickerHorizontalPadding;
  }
}

Size measureWidget(Widget widget) {
  final PipelineOwner pipelineOwner = PipelineOwner();
  final MeasurementView rootView = pipelineOwner.rootNode = MeasurementView();
  final BuildOwner buildOwner = BuildOwner(focusManager: FocusManager());
  final RenderObjectToWidgetElement<RenderBox> element =
      RenderObjectToWidgetAdapter<RenderBox>(
    container: rootView,
    debugShortDescription: '[root]',
    child: widget,
  ).attachToRenderTree(buildOwner);
  try {
    rootView.scheduleInitialLayout();
    pipelineOwner.flushLayout();
    return rootView.size;
  } finally {
    // Clean up.
    element.update(RenderObjectToWidgetAdapter<RenderBox>(container: rootView));
    buildOwner.finalizeTree();
  }
}

class MeasurementView extends RenderBox
    with RenderObjectWithChildMixin<RenderBox> {
  @override
  void performLayout() {
    assert(child != null);
    child!.layout(const BoxConstraints(), parentUsesSize: true);
    size = child!.size;
  }

  @override
  void debugAssertDoesMeetConstraints() => true;
}
MendelG
  • 14,885
  • 4
  • 25
  • 52
Sayyid J
  • 1,215
  • 1
  • 4
  • 18
0

you could try to use c padding dynamically

padding: EdgeInsets.only(left: 0.1 * [index], right: 1 * [index])

for example, I hope it helps.