90

I want to create a list of cards scrolling horizontally with snap to fit effect when swiped either from left or right.

Each card has some spacing between them and fit to screen similar to below image

enter image description here

Apart from that these horizontally scrollable list elements should be contained inside a vertically scrollable list.

I all I am able to achieve is only displaying a list of horizontal scrolling cards after following example in flutter docs.

class SnapCarousel extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final title = 'Horizontal List';

    return MaterialApp(
      title: title,
      home: Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: Container(
          margin: EdgeInsets.symmetric(vertical: 20.0),
          height: 200.0,
          child: ListView(
            scrollDirection: Axis.horizontal,
            children: <Widget>[
              Container(
                width: 160.0,
                color: Colors.red,
              ),
              Container(
                width: 160.0,
                color: Colors.blue,
              ),
              Container(
                width: 160.0,
                color: Colors.green,
              ),
              Container(
                width: 160.0,
                color: Colors.yellow,
              ),
              Container(
                width: 160.0,
                color: Colors.orange,
              ),
            ],
          ),
        ),
      ),
    );
  }
}
WitVault
  • 23,445
  • 19
  • 103
  • 133
  • 4
    Possible duplicate of [Creating Image Carousel in Flutter](https://stackoverflow.com/questions/47349784/creating-image-carousel-in-flutter) – Rémi Rousselet Jul 31 '18 at 07:44
  • 2
    @RémiRousselet Above link partially solves my issue as I also wanted to put them inside a vertically scrollable list. Each component inside vertically scrollable list will be a collection of elements which will scroll horizontally. – WitVault Jul 31 '18 at 08:49
  • Nothing prevents you from doing it with the previous link – Rémi Rousselet Jul 31 '18 at 08:59
  • 1
    @RémiRousselet can you please provide a basic example of it I don't know how to make it vertically scrollable. – WitVault Jul 31 '18 at 10:29
  • You don't make it vertically scrollable. Wrap it inside a `ListView` instead – Rémi Rousselet Jul 31 '18 at 10:50
  • You can find a similar answer [here](https://stackoverflow.com/a/67534858/9467181) – Pratik May 14 '21 at 13:10

8 Answers8

119

Use PageView and ListView:

import 'package:flutter/material.dart';

main() => runApp(MaterialApp(home: MyHomePage()));

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Carousel in vertical scrollable'),
      ),
      body: ListView.builder(
        padding: EdgeInsets.symmetric(vertical: 16.0),
        itemBuilder: (BuildContext context, int index) {
          if(index % 2 == 0) {
            return _buildCarousel(context, index ~/ 2);
          }
          else {
            return Divider();
          }
        },
      ),
    );
  }

  Widget _buildCarousel(BuildContext context, int carouselIndex) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        Text('Carousel $carouselIndex'),
        SizedBox(
          // you may want to use an aspect ratio here for tablet support
          height: 200.0,
          child: PageView.builder(
            // store this controller in a State to save the carousel scroll position
            controller: PageController(viewportFraction: 0.8),
            itemBuilder: (BuildContext context, int itemIndex) {
              return _buildCarouselItem(context, carouselIndex, itemIndex);
            },
          ),
        )
      ],
    );
  }

  Widget _buildCarouselItem(BuildContext context, int carouselIndex, int itemIndex) {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 4.0),
      child: Container(
        decoration: BoxDecoration(
          color: Colors.grey,
          borderRadius: BorderRadius.all(Radius.circular(4.0)),
        ),
      ),
    );
  }
}
boformer
  • 28,207
  • 10
  • 81
  • 66
97

Screenshot:

enter image description here


If you don't want to use any 3rd party packages, you can simply try this:

class _HomePageState extends State<HomePage> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: SizedBox(
          height: 200, // card height
          child: PageView.builder(
            itemCount: 10,
            controller: PageController(viewportFraction: 0.7),
            onPageChanged: (int index) => setState(() => _index = index),
            itemBuilder: (_, i) {
              return Transform.scale(
                scale: i == _index ? 1 : 0.9,
                child: Card(
                  elevation: 6,
                  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
                  child: Center(
                    child: Text(
                      "Card ${i + 1}",
                      style: TextStyle(fontSize: 32),
                    ),
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}
CopsOnRoad
  • 237,138
  • 77
  • 654
  • 440
  • This is great! But I'm left wondering, how do you set the distance between items? I wanted a smaller gap? – K-Dawg Jun 04 '20 at 18:21
  • 3
    @PrimeByDesign Update `scale` value from `0.9` to `0.95` – CopsOnRoad Jun 05 '20 at 15:39
  • when loading itself there is margin in the left, how to remove that? – Nifal Nizar Oct 11 '20 at 01:54
  • @NifalNizar Which margin are you talking about? – CopsOnRoad Oct 11 '20 at 04:45
  • I'm getting a small white space before the first item https://drive.google.com/file/d/1m3SC7mpLd6tx2iPoMMoMnLCSoUURqCSx/view?usp=sharing – Nifal Nizar Oct 11 '20 at 08:16
  • @NifalNizar I need access to view the file. Can you make that public? – CopsOnRoad Oct 11 '20 at 16:54
  • https://drive.google.com/file/d/1m3SC7mpLd6tx2iPoMMoMnLCSoUURqCSx/view?usp=sharing – Nifal Nizar Oct 12 '20 at 02:49
  • @NifalNizar Alright, so you're talking about the initial spacing, well I need to tweak few things here and there, currently not able to work for last few days, let me get back to the machine in a day or so, I'll update the solution – CopsOnRoad Oct 12 '20 at 07:56
  • @CopsOnRoad thanks, how can we have multiple items around center item? in your implementation we have only one item, how can we could have for example 2 item? and changing opacity like screen shot which post owner attached? – DolDurma Oct 27 '20 at 16:52
  • @DolDurma You can set `viewportFraction` to something like `0.5`, and also reduce the `scale` value but then it will not serve the purpose for a `PageView`. For your case, I'll suggest you to use a horizontal `ListView`. And for opacity, simply wrap the `Card` widget inside an `Opacity` widget and set `opacity: i == _index ? 1 : 0.5` – CopsOnRoad Oct 28 '20 at 00:11
  • Any answer of NifalNizar question? @CopsOnRoad? – Ufuk Zimmerman Apr 26 '21 at 12:00
  • can one use a PageView in this context without a fixed height, more like it should wrap it's content. – Paul Okeke May 03 '21 at 00:23
  • How can i make each card display different texts?? – Patiee Mar 20 '22 at 18:10
  • This is stellar mate for an image picker, thank you very much – Lerex Nov 20 '22 at 09:13
  • If you want to add animation when the card is focused and when the card is shrink, u can see my answer here [link](https://stackoverflow.com/a/75495436/11451151) – anas Feb 18 '23 at 18:27
33

this is an old question, and I arrived here looking for something else ;-), but what WitVault was lookig is done easy with this package: https://pub.dev/packages/flutter_swiper

Demo image

The implementation:

Put the dependencies in pubsec.yaml:

dependencies:
   flutter_swiper: ^1.1.6

Import it in the page where you need it:

import 'package:flutter_swiper/flutter_swiper.dart';

In the layout:

new Swiper(
  itemBuilder: (BuildContext context, int index) {
    return new Image.network(
      "http://via.placeholder.com/288x188",
      fit: BoxFit.fill,
    );
  },
  itemCount: 10,
  viewportFraction: 0.8,
  scale: 0.9,
)
Daniel
  • 1,007
  • 1
  • 11
  • 23
  • 1
    We tried this package. Experience was below expectations for us. Probably because of the large amount of unresolved issues (158 issues at the time of writing - mostly "help wanted"). https://github.com/best-flutter/flutter_swiper/issues -- we settled for another solution. – Alexandre Jean Aug 07 '20 at 05:34
  • 3
    @AlexJean please share the other solution with the community – Marcos Maliki Aug 09 '20 at 07:41
  • PageView as indicated in the duplicate of this question is what we settled for. Other solutions in this post and its duplicate were comfortable as well. Duplicate https://stackoverflow.com/questions/47349784/creating-image-carousel-in-flutter – Alexandre Jean Aug 11 '20 at 05:02
  • As there seems to be interest - we also used successfully this popular package https://pub.dev/packages/carousel_slider that has so far not been mentioned among the answers. – Alexandre Jean Sep 13 '20 at 14:59
  • It looks like `flutter_swiper` is not maintained (not even null safety). Found a new fork of it named [card_swiper](https://pub.dev/packages/card_swiper), it is a little bit newer. – Guy Luz Jan 19 '23 at 09:34
  • @GuyLuz or you can do it yourself without package. check my answer here [answer](https://stackoverflow.com/a/75495436/11451151) – anas Feb 18 '23 at 18:29
32

To achieve the snap effect via ListView, just set the physics to PageScrollPhysics

const List<Widget> children = [
  ContainerCard(),
  ContainerCard(),
  ContainerCard(),
];
ListView.builder(
    scrollDirection: Axis.horizontal,
    physics: const PageScrollPhysics(), // this for snapping
    itemCount: children.length,
    itemBuilder: (_, index) => children[index],
  )
Hazem Monir
  • 330
  • 4
  • 5
2

enter image description here


I believe the answer solution from CopsOnRoad is better and simple for someone who don't want to use a 3rd party library. However, since there is no animation, I add the scale animation when the card is viewed (expand) and the previous card is swiped (shrink) using index. So what happened is whenever the first time the page load, 1st and 2nd card won't have any animation, and when the card is swiped, only the previous and current card have the scale animation. So this is my implementation:

class MyHomePage extends StatefulWidget {


const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
  int currentIndex = -1, previousIndex = 0;

  double getAnimationValue(int currentIndex, int widgetIndex, int previousIndex,
      {bool begin = true}) {
    if (widgetIndex == currentIndex) {
      return begin ? 0.9 : 1;
    } else {
      return begin ? 1 : 0.9;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          SizedBox(
            height: 200, // card height
            child: PageView.builder(
              itemCount: 10,
              controller: PageController(viewportFraction: 0.7),
              onPageChanged: (int index) {
                setState(() {
                  if (currentIndex != -1) {
                    previousIndex = currentIndex;
                  }
                  currentIndex = index;
                });
              },
              itemBuilder: (_, widgetIndex) {
                return (currentIndex != -1 &&
                        (previousIndex == widgetIndex ||
                            widgetIndex == currentIndex))
                    ? TweenAnimationBuilder(
                        duration: const Duration(milliseconds: 400),
                        tween: Tween<double>(
                          begin: getAnimationValue(
                            currentIndex,
                            widgetIndex,
                            previousIndex,
                          ),
                          end: getAnimationValue(
                            currentIndex,
                            widgetIndex,
                            previousIndex,
                            begin: false,
                          ),
                        ),
                        builder: (context, value, child) {
                          return Transform.scale(
                            scale: value,
                            child: Card(
                              elevation: 6,
                              shape: RoundedRectangleBorder(
                                  borderRadius: BorderRadius.circular(20)),
                              child: Column(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  Text(
                                    "Card${widgetIndex + 1}",
                                    style: const TextStyle(fontSize: 30),
                                  ),
                                  Text(
                                    "$widgetIndex >> Widget Index << $widgetIndex",
                                    style: const TextStyle(fontSize: 22),
                                  ),
                                  Text(
                                    "$currentIndex >> Current Index << $currentIndex",
                                    style: const TextStyle(fontSize: 22),
                                  ),
                                  Text(
                                    "$previousIndex >> Previous Index << $previousIndex",
                                    style: const TextStyle(fontSize: 22),
                                  ),
                                ],
                              ),
                            ),
                          );
                        },
                      )
                    : Transform.scale(
                        // this is used when you want to disable animation when initialized the page
                        scale:
                            (widgetIndex == 0 && currentIndex == -1) ? 1 : 0.9,
                        child: Card(
                          elevation: 6,
                          shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(20)),
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              Text(
                                "Card${widgetIndex + 1}",
                                style: const TextStyle(fontSize: 30),
                              ),
                              Text(
                                "$widgetIndex >> Widget Index << $widgetIndex",
                                style: const TextStyle(fontSize: 22),
                              ),
                              Text(
                                "$currentIndex >> Init Index << $currentIndex",
                                style: const TextStyle(fontSize: 22),
                              ),
                              Text(
                                "$previousIndex >> Previous Index << $previousIndex",
                                style: const TextStyle(fontSize: 22),
                              ),
                            ],
                          ),
                        ),
                      );
              },
            ),
          ),
        ],
      ),
    );
  }
}

I used TweenAnimationBuilder for this animation and hardcoded the widget. You can use method for your widget or use package flutter_animate for easy animation whenever necessary.

anas
  • 162
  • 1
  • 10
1

Advanced Snap List

If you are looking for advanced usages, such as dynamic item sizes, configurable snap points, visualization of items, and essential control (such as scrollToIndex, animate) you should use the native-based SnappyListView with way more features.

SnappyListView(
  itemCount: Colors.accents.length,
  itemBuilder: (context, index) {
    return Container(
        height: 100,
        color: Colors.accents.elementAt(index),
        child: Text("Index: $index"),
    ),
);
CopsOnRoad
  • 237,138
  • 77
  • 654
  • 440
Paul
  • 1,349
  • 1
  • 14
  • 26
0

If you want to use a ListView, and your items are of fixed with, you can use an implementation of ScrollPhysics based on PageScrollPhysics used by the PageView. This has the limitation that it only works for equally sized children.

import 'package:flutter/material.dart';

class SnapScrollPhysics extends ScrollPhysics {
  const SnapScrollPhysics({super.parent, required this.snapSize});

  final double snapSize;

  @override
  SnapScrollSize applyTo(ScrollPhysics? ancestor) {
    return SnapScrollSize(parent: buildParent(ancestor), snapSize: snapSize);
  }

  double _getPage(ScrollMetrics position) {
    return position.pixels / snapSize;
  }

  double _getPixels(ScrollMetrics position, double page) {
    return page * snapSize;
  }

  double _getTargetPixels(
      ScrollMetrics position, Tolerance tolerance, double velocity) {
    double page = _getPage(position);
    if (velocity < -tolerance.velocity) {
      page -= 0.5;
    } else if (velocity > tolerance.velocity) {
      page += 0.5;
    }
    return _getPixels(position, page.roundToDouble());
  }

  @override
  Simulation? createBallisticSimulation(
      ScrollMetrics position, double velocity) {
    // If we're out of range and not headed back in range, defer to the parent
    // ballistics, which should put us back in range at a page boundary.
    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
      return super.createBallisticSimulation(position, velocity);
    }
    final Tolerance tolerance = this.tolerance;
    final double target = _getTargetPixels(position, tolerance, velocity);
    if (target != position.pixels) {
      return ScrollSpringSimulation(spring, position.pixels, target, velocity,
          tolerance: tolerance);
    }
    return null;
  }

  @override
  bool get allowImplicitScrolling => false;
}

You can see it in action here:


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView(
        physics: SnapScrollPhysics(snapSize: MediaQuery.of(context).size.width/3),
        scrollDirection: Axis.horizontal,
        children: <Widget>[
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[900],
            child: const Center(child: Text('Entry A')),
          ),
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[800],
            child: const Center(child: Text('Entry B')),
          ),
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[700],
            child: const Center(child: Text('Entry C')),
          ),
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[600],
            child: const Center(child: Text('Entry D')),
          ),
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[500],
            child: const Center(child: Text('Entry E')),
          ),
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[400],
            child: const Center(child: Text('Entry F')),
          ),
          Container(
            width: MediaQuery.of(context).size.width/3,
            color: Colors.amber[300],
            child: const Center(child: Text('Entry G')),
          ),
        ],
      ),
    );
  }
}


class SnapScrollSize extends ScrollPhysics {
  const SnapScrollSize({super.parent, required this.snapSize});

  final double snapSize;

  @override
  SnapScrollSize applyTo(ScrollPhysics? ancestor) {
    return SnapScrollSize(parent: buildParent(ancestor), snapSize: snapSize);
  }

  double _getPage(ScrollMetrics position) {
    return position.pixels / snapSize;
  }

  double _getPixels(ScrollMetrics position, double page) {
    return page * snapSize;
  }

  double _getTargetPixels(
      ScrollMetrics position, Tolerance tolerance, double velocity) {
    double page = _getPage(position);
    if (velocity < -tolerance.velocity) {
      page -= 0.5;
    } else if (velocity > tolerance.velocity) {
      page += 0.5;
    }
    return _getPixels(position, page.roundToDouble());
  }

  @override
  Simulation? createBallisticSimulation(
      ScrollMetrics position, double velocity) {
    // If we're out of range and not headed back in range, defer to the parent
    // ballistics, which should put us back in range at a page boundary.
    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
      return super.createBallisticSimulation(position, velocity);
    }
    final Tolerance tolerance = this.tolerance;
    final double target = _getTargetPixels(position, tolerance, velocity);
    if (target != position.pixels) {
      return ScrollSpringSimulation(spring, position.pixels, target, velocity,
          tolerance: tolerance);
    }
    return null;
  }

  @override
  bool get allowImplicitScrolling => false;
}

mik.corcuera
  • 106
  • 1
  • 5
0

If all of your items are of the same width, you can use these custom scroll physics I wrote, which is based on PageScrollPhysics.

It also accounts for overscrolling, rapid scrolling, element centering, and the horizontal padding of the ListView which should be equal to the padding between the items.

class _SnapPageScrollPhysics extends ScrollPhysics {
  const _SnapPageScrollPhysics({
    super.parent,
    required this.elementWidth,
    required this.elementPadding,
  });

  final double elementWidth;
  final double elementPadding;

  @override
  _SnapPageScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return _SnapPageScrollPhysics(
      parent: buildParent(ancestor),
      elementWidth: elementWidth,
      elementPadding: elementPadding,
    );
  }

  double _getTargetPixels(
    ScrollMetrics position,
    Tolerance tolerance,
    double velocity,
  ) {
    final pageWidth = elementWidth + elementPadding;
    final page = position.pixels / pageWidth + velocity / 3000;
    final offset = (position.viewportDimension - elementWidth) / 2;
    final target = page.roundToDouble() * pageWidth - offset + elementPadding;
    return max(0, min(target, position.maxScrollExtent));
  }

  @override
  Simulation? createBallisticSimulation(
    ScrollMetrics position,
    double velocity,
  ) {
    // If we're out of range and not headed back in range, defer to the parent
    // ballistics, which should put us back in range at a page boundary.
    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
      return super.createBallisticSimulation(position, velocity);
    }
    final tolerance = toleranceFor(position);
    final target = _getTargetPixels(position, tolerance, velocity);
    if (target != position.pixels) {
      return ScrollSpringSimulation(
        spring,
        position.pixels,
        target,
        velocity,
        tolerance: tolerance,
      );
    }
    return null;
  }

  @override
  bool get allowImplicitScrolling => false;
}

For example:

ListView.separated(
  physics: _SnapPageScrollPhysics(
    elementPadding: padding,
    elementWidth: _width,
  ),
  padding: EdgeInsets.symmetric(horizontal: padding),
  scrollDirection: Axis.horizontal,
  itemBuilder: itemBuilder,
  itemCount: itemCount,
  separatorBuilder: (context, index) => SizedBox(width: padding),
),