Baran S.
Baran S.

Reputation: 53

How can I achieve "child of a widget which is inside a scroll widget acting like sticky header" in Flutter?

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.

Upvotes: 5

Views: 493

Answers (2)

Sayyid J
Sayyid J

Reputation: 1551

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

Upvotes: 4

Antonio Arista
Antonio Arista

Reputation: 1

you could try to use c padding dynamically

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

for example, I hope it helps.

Upvotes: 0

Related Questions