skip to Main Content

enter image description here

How can we make this kind of list behavior?

As input parameters we have

  1. index of the element to be fixed (can be found in advance if necessary)
  2. list of items

I can imagine a way to fix the element on top, although the way is depressing:


final indexPinnedItem = 23;

final listBefore = [...all elements up to 23];
final listAfter = [...all elements after 23];

return CustomScrollView(
      slivers: [
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              return listBefore; // elements with index from 0 to 22 inclusive
            }),
        ),
        SliverPinnedHeader(
          child: // element with index 23,
        ),
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              return listAfter; // elements with index from 24 to
            }),
        ),
      ],
    );

Note that the height of the element can be different. Size of fixed element and element in "free float" are the same (in fact, they are the same widget. I don’t need it to start changing size, shape, or anything else as soon as an item becomes fixed).

How can this behavior be achieved?

Update:

The whole sacred point is that I want to see the current item selected in the big list, which does not fit completely on the screen.

-> Suppose that our selected item has the sequence number 23. When we go to the screen, we only see items 1 through 4. And therefore, the 23rd element must be secured from below (Fig. 1).

-> Now we scroll down to 23 items and it is automatically detached and visible anywhere in the list (Fig. 2).

-> But as soon as an item is out of view, it’s automatically re-locked (at the bottom or top, depending on where we’re scrolling at the moment) (Fig. 3, 4).

4

Answers


  1. Chosen as BEST ANSWER

    Thanks colleagues, solution found based on suggestions from @AJ- и @magesh magi

    I used the split list to set SliverPinnedHeader from the sliver_tools package in between. This is how I achieved fixing the element at the top.

    To pin the element at the bottom, I used Column and visibility state tracking with the visibility_detector package.

    Thus, the item can be anchored dynamically at the top or bottom, depending on its position in the list and current visibility.

    The code for our list looks like this:

    (I used hooks to shorten the code and make it easier to understand.)

    class PinnedList extends HookWidget {
      const PinnedList({
        Key? key,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        print(PinnedList);
    
        // Set the minimum value to see a smooth animation
        VisibilityDetectorController.instance.updateInterval =
            const Duration(milliseconds: 1);
    
        const pinnedItemIndex = 23;
    
        final products = List.generate(
            100,
            (index) => ProductItem(
                  'Product $index',
                  index == pinnedItemIndex ? true : false,
                ));
    
        final productsBefore = products.sublist(0, pinnedItemIndex);
        final productsAfter = products.sublist(pinnedItemIndex + 1);
    
        final selectedProduct = products[pinnedItemIndex];
    
        final animation = useAnimationController(initialValue: 1);
    
        final selectedItemWidget = VisibilityDetector(
          key: ValueKey(selectedProduct.name),
          onVisibilityChanged: (VisibilityInfo info) {
            animation.value = 1 - info.visibleFraction;
          },
          child: Tile(
            product: selectedProduct,
            color: Colors.red,
          ),
        );
    
        return Column(
          children: [
            Expanded(
              child: CustomScrollView(
                physics: const BouncingScrollPhysics(),
                slivers: [
                  getSliverList(productsBefore),
                  SliverPinnedHeader(child: selectedItemWidget),
                  getSliverList(productsAfter),
                ],
              ),
            ),
            BottomTile(
              product: selectedProduct,
              animation: animation,
            ),
          ],
        );
      }
    
      SliverList getSliverList(List<ProductItem> items) => SliverList(
            delegate: SliverChildBuilderDelegate(
              childCount: items.length,
              (context, index) {
                return Tile(product: items[index]);
              },
            ),
          );
    }
    

    A widget to pin at the bottom of the list:

    class BottomTile extends StatelessWidget {
      const BottomTile({
        Key? key,
        required this.product,
        required this.animation,
      }) : super(key: key);
    
      final ProductItem product;
      final AnimationController animation;
    
      @override
      Widget build(BuildContext context) {
        return ClipRect(
          child: AnimatedBuilder(
            animation: animation,
            builder: (context, child) {
              return Align(
                alignment: Alignment.bottomCenter,
                heightFactor: animation.value,
                child: child,
              );
            },
            child: Tile(
              product: product,
              color: Colors.blue,
            ),
          ),
        );
      }
    }
    

    And the card widget itself (be sure to wrap in Material):

    class Tile extends StatelessWidget {
      const Tile({
        Key? key,
        required this.product,
        this.color,
      }) : super(key: key);
    
      final ProductItem product;
      final Color? color;
    
      @override
      Widget build(BuildContext context) {
        return Material(
          type: MaterialType.card,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: ListTile(
              onTap: () {},
              title: Text(product.name),
              selectedTileColor: color,
              leading: Icon(
                Icons.star,
                color: product.isPin ? Colors.yellow : Colors.grey,
              ),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(8.0),
                side: const BorderSide(),
              ),
              selected: product.isPin,
            ),
          ),
        );
      }
    }
    
    class ProductItem {
      ProductItem(this.name, this.isPin);
      final String name;
      final bool isPin;
    }
    

    I purposely changed the color of the widget when it starts attaching to the bottom edge. Just to show the animation, which can be anything.

    Performance: There is nothing wrong with performance because:

    1. we don't rearrange the lists
    2. do not rebuild widgets that do not need it
    3. not bound to the size of the widget, not dependent on scrollController and other things (conditionally, because we use the package visibility_detector)

    You can run the example and find the full code in the repository here: github.com/PackRuble/dynamic_pin_list


  2. Here is the edited solution after you provided more informations about what you are trying to achieve.

    For the Pinned tile, I used the same package as you, for simplicity sake, but the pinned effect is easily achievable by just wrapping CustomScrollView in a Stack widget and then playing a bit with a Positioned widget to create a top sticky tile.

    The logic is to create a Class, that has a isPinned property, and mapping all your list elements to such a class.
    With this step, you can now track each widget’s state.

    You’ll now create a couple of utility methods that will loop over your list, and set / unset the pinned state.

    In the following working example, I implemented the top pinned and the bottom pinned feature, and it works by using a NotificationListener and each time you set as pinned an item, it will save the scroll position on the y axis, and thanks to that, with the NotificationListener, you can determine if the pinned item should be displayed at the top or at the bottom

    When you set a widget as pinned, it will only get pinned when it’s not in the view, to detect the visibility, I’m using a really good and supported package, visibility_detector, maintained by the Google team.

    enter image description here

    This is the custom Class that will be mapped to your list:

    class TileData {
      final int id;
      final String title;
      bool isPinned;
    
      TileData({required this.id, required this.title, this.isPinned = false});
    }
    

    This is your view, that displays all the UI:

    class ListTest2 extends StatefulWidget {
      ListTest2();
    
      @override
      State<ListTest2> createState() => _ListTest2State();
    }
    
    class _ListTest2State extends State<ListTest2> {
      late final List<TileData> listData;
      late ScrollController controller;
    
      bool isPinnedWidgetTop = true;
    
      double pinnedScrollingPositionInList = 0;
    
      int? pinnedWidgetId;
      bool showPinned = false;
    
      @override
      void initState() {
        listData = List.generate(40, (index) => TileData(id: index, title: "Hello $index"));
    
        controller = ScrollController();
    
        super.initState();
      }
    
      void pinItem(int id) {
        listData.forEach((e) {
          e.isPinned = false;
    
          if (e.id == id) {
            e.isPinned = true;
            pinnedWidgetId = e.id;
          }
        });
    
        // whenever we pin a new item, we set the `showPinned` to false, to avoid showing in the same frame the newly pinned item, in the pin area
        showPinned = false;
    
        // whenever we pin an item, we save the controller position, to know where should we pin it
        pinnedScrollingPositionInList = controller.position.pixels;
    
      }
    
      void unpin(int id) {
        listData.firstWhereOrNull((e) => e.id == id)?.isPinned = false;
        pinnedWidgetId = null;
      }
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            NotificationListener<ScrollUpdateNotification>(
            onNotification: (ScrollNotification scrollInfo) {
              if (pinnedWidgetId != null) {
                if (pinnedScrollingPositionInList > controller.position.pixels) {
                  if (isPinnedWidgetTop) setState(() => isPinnedWidgetTop = false);
                  
                } else if (pinnedScrollingPositionInList < controller.position.pixels) {
                  if (!isPinnedWidgetTop) setState(() => isPinnedWidgetTop = true);
                }
              }
              return true;
            },
              child: CustomScrollView(
                controller: controller,
                slivers: [
                  if (pinnedWidgetId != null && showPinned && isPinnedWidgetTop)
                    SliverPinnedHeader(
                      child: Container(
                        color: Colors.white,
                        child: CustomListTile(
                          data: listData[pinnedWidgetId!],
                          isPinnedDisplayed: showPinned,
                          onPressed: () => setState(() {
                            listData[pinnedWidgetId!].isPinned ? unpin(listData[pinnedWidgetId!].id) : pinItem(listData[pinnedWidgetId!].id);
                          }),
                          isPinnedTile: true,
                        ),
                      ),
                    ),
                  SliverList(
                    delegate: SliverChildBuilderDelegate(
                      (BuildContext context, int index) {
                        return VisibilityDetector(
                          key: Key("${listData[index].id}"),
                          onVisibilityChanged: (visibilityInfo) {
                            if (listData[index].isPinned) {
                              if (visibilityInfo.visibleFraction == 0) {
                                setState(() {
                                  showPinned = true;
                                });
                              } else if (visibilityInfo.visibleFraction != 0 && showPinned) {
                                setState(() {
                                  showPinned = false;
                                });
                              }
                            }
                          },
                          child: CustomListTile(
                            data: listData[index],
                            isPinnedDisplayed: showPinned,
                            onPressed: () => setState(() {
                              listData[index].isPinned ? unpin(listData[index].id) : pinItem(listData[index].id);
                            }),
                          ),
                        );
                      },
                      childCount: listData.length,
                    ),
                  ),
                ],
              ),
            ),
            if (pinnedWidgetId != null && showPinned && !isPinnedWidgetTop)
            Positioned(
              bottom: 0,
              child: Container(
                color: Colors.white,
                width: MediaQuery.of(context).size.width,
                child: CustomListTile(
                    data: listData[pinnedWidgetId!],
                    isPinnedDisplayed: showPinned,
                    onPressed: () => setState(() {
                      listData[pinnedWidgetId!].isPinned ? unpin(listData[pinnedWidgetId!].id) : pinItem(listData[pinnedWidgetId!].id);
                    }),
                    isPinnedTile: true,
                  ),
              ),
            )
          ],
        );
      }
    }
    

    and finally this is the ListTile custom widget, that I extracted in a separate class:

    class CustomListTile extends StatelessWidget {
      final TileData data;
      final void Function()? onPressed;
      final bool isPinnedTile;
      final bool isPinnedDisplayed;
    
      CustomListTile({required this.data, this.onPressed, this.isPinnedTile = false, required this.isPinnedDisplayed});
    
      @override
      Widget build(BuildContext context) {
        return !isPinnedTile && data.isPinned && isPinnedDisplayed
            ? const SizedBox(
                height: 1,
              )
            : Padding(
                padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                child: Container(
                  height: 75,
                  decoration: BoxDecoration(border: Border.all(color: Colors.black), color: Colors.white),
                  child: ListTile(
                    title: Text(data.title),
                    leading: Container(
                      width: 100,
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          IconButton(
                            icon: Icon(
                              Icons.star,
                              color: data.isPinned ? Colors.yellow : Colors.grey,
                            ),
                            visualDensity: const VisualDensity(horizontal: -4.0, vertical: -4.0),
                            onPressed: onPressed,
                          ),
                          data.isPinned ? const Text("UNPIN") : const Text("Pin")
                        ],
                      ),
                    ),
                  ),
                ),
              );
      }
    }
    

    For any question feel free to ask!

    Login or Signup to reply.
  3. It seems to me that what you are describing is best achieved with a Stack and an Align widget. That is, wrap your ListView (or equivalent widget) in a Stack, then place a copy of the selected item in an Align and put that Align into the Stack after the ListView. Something like this:

    Stack(
      children: [
            ListView(<your stuff here>),
            Align(
               alignment: Alignment.bottomLeft,
               child: <selected item>,
            ),
        ],
    );
    

    The result, if executed properly, should be a an item that is rendered above the list with whatever alignment you prefer. The list will scroll up and down behind the item and the item would always be visible. Make sure to use shrinkWrap: true or equivalent because otherwise height will be unbounded.

    Align: https://api.flutter.dev/flutter/widgets/Align-class.html

    Stack: https://api.flutter.dev/flutter/widgets/Stack-class.html

    Login or Signup to reply.
  4. This may be work around for how you want. u can change to builders.

    import 'package:flutter/material.dart';
    
    class DynoScroll extends StatefulWidget {
      DynoScroll({Key? key}) : super(key: key);
    
      @override
      State<DynoScroll> createState() => _DynoScrollState();
    }
    
    class _DynoScrollState extends State<DynoScroll> {
      final ScrollController controller = new ScrollController();
      int itemcount = 100;
      int pinnedIndex = 23;
      bool showPinnedtop = false;
      bool showPinnedbottom = false;
      @override
      void initState() {
        super.initState();
        initScroll();
      }
    
      void initScroll() {
        controller.addListener(() {
          if ((pinnedIndex+8) * 50  < controller.offset) {
            showPinnedtop = true;
            showPinnedbottom = false;
            setState(() {});       
          } else {
            showPinnedtop = false;
            setState(() {});
          }
        });
      }
    
      @override
      void dispose() {
        super.dispose();
        controller.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Column(
                children: [
                  if (showPinnedtop)
                    Container(
                      height: 50,
                      padding: EdgeInsets.all(8.0),
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(
                            5,
                          ),
                          color: Colors.blue),
                      child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            Icon(
                              Icons.star,
                              color: Colors.yellow,
                            ),
                            Text("$pinnedIndex"),
                            Text("")
                          ]),
                    ),
                  Expanded(
                    child: SingleChildScrollView(
                      controller: controller,
                      child: Column(
                        children: [
                          for (var i = 0; i < itemcount; i++)
                            Padding(
                              padding: const EdgeInsets.all(8.0),
                              child: Container(
                                height: 50,
                                padding: EdgeInsets.all(8.0),
                                decoration: BoxDecoration(
                                    borderRadius: BorderRadius.circular(
                                      5,
                                    ),
                                    color: Colors.blue),
                                child: Row(
                                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                                  children: [
                                    Icon(
                                      pinnedIndex == i
                                          ? Icons.star
                                          : Icons.star_border_outlined,
                                      color: Colors.yellow,
                                    ),
                                    Text("$i"),
                                    Text("")
                                  ],
                                ),
                              ),
                            )
                        ],
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      }
    }
    

    I just calculate to show and hide top pinned widget by using height of widget in controller of scroll position. same way u can calculate for bottom.

    enter image description here

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search