Search code examples
flutterdartflutter-animationflutter-pageview

implementing custom scrolling like Chanel app in flutter?


Recently I installed a new app called Chanel Fashion, on it's home page there is a very strange type of scrolling, which you can see it from below GIF, I highly doubt it's a customized scroller of anytype, I think it's a pageview, any hints on how can I implement such a thing in flutter?

enter image description here

P.s this blog tried to make something like that in android but it's different in many ways.

P.s 2 this SO question tried to implement it on IOS.


Solution

  • This is my demo

    demo chanel scroll

    library in demo: interpolate: ^1.0.2+2

    main.dart

    import 'package:chanel_scroll_animation/chanel1/chanel1_page.dart';
    import 'package:flutter/material.dart';
    void main() {
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            // This is the theme of your application.
            //
            // Try running your application with "flutter run". You'll see the
            // application has a blue toolbar. Then, without quitting the app, try
            // changing the primarySwatch below to Colors.green and then invoke
            // "hot reload" (press "r" in the console where you ran "flutter run",
            // or simply save your changes to "hot reload" in a Flutter IDE).
            // Notice that the counter didn't reset back to zero; the application
            // is not restarted.
            primarySwatch: Colors.blue,
          ),
          home: Chanel1Page(),
        );
      }
    }
    

    chanel1_page.dart

    import 'package:chanel_scroll_animation/chanel1/item.dart';
    import 'package:chanel_scroll_animation/chanel1/snapping_list_view.dart';
    import 'package:chanel_scroll_animation/models/model.dart';
    import 'package:flutter/material.dart';
    
    
    class Chanel1Page extends StatefulWidget {
      @override
      _Chanel1PageState createState() => _Chanel1PageState();
    }
    
    class _Chanel1PageState extends State<Chanel1Page> {
      ScrollController _scrollController;
      double y=0;
      double maxHeight=0;
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
        _scrollController=new ScrollController();
        _scrollController.addListener(() {
          print("_scrollController.offset.toString() "+_scrollController.offset.toString());
    
    
          setState(() {
            y=_scrollController.offset;
          });
    
        });
        WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
          final Size size=MediaQuery.of(context).size;
          setState(() {
            maxHeight=size.height/2;
          });
    
        });
    
      }
    
    
      @override
      Widget build(BuildContext context) {
    
        return Scaffold(
          body: SafeArea(
            child: maxHeight!=0?SnappingListView(
              controller: _scrollController,
                snapToInterval: maxHeight,
                scrollDirection: Axis.vertical,
              children: [
    
                Container(
                  height:  ( models.length +1) * maxHeight,
    
                  child: Column(
                    children: [
                      for (int i = 0; i < models.length; i++)
                        Item(item: models[i],index: i,y: y,)
                    ],
                  ),
                )
    
              ],
            ):Container(),
          ),
    
        );
      }
    }
    

    item.dart

    import 'package:chanel_scroll_animation/models/model.dart';
    import 'package:flutter/material.dart';
    import 'package:interpolate/interpolate.dart';
    
    const double MIN_HEIGHT = 128;
    class Item extends StatefulWidget {
      final Model item;
      final int index;
      final double y;
      Item({this.item,this.index,this.y});
    
      @override
      _ItemState createState() => _ItemState();
    }
    
    class _ItemState extends State<Item> {
    
      Interpolate ipHeight;
      double maxHeight=0;
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
       WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
          final Size size=MediaQuery.of(context).size;
         maxHeight=size.height/2;
         initInterpolate();
       });
      }
    
      initInterpolate()
      {
        ipHeight=Interpolate(
          inputRange: [(widget.index-1)*maxHeight,widget.index*maxHeight],
          outputRange: [MIN_HEIGHT,maxHeight],
          extrapolate: Extrapolate.clamp,
        );
      }
      @override
      Widget build(BuildContext context) {
        final Size size=MediaQuery.of(context).size;
        double height=ipHeight!=null? ipHeight.eval(widget.y):MIN_HEIGHT;
        print("height "+height.toString());
    
        return Container(
          height: height,
          child: Stack(
            children: [
              Positioned.fill(
                child: Image.asset(
                  widget.item.picture,
                  fit: BoxFit.cover,
                ),
              ),
              Positioned(
                bottom:40,
                left: 30,
                right: 30,
                child: Column(
                  children: [
                    Text(
                      widget.item.subtitle,
                      style: TextStyle(fontSize: 16, color: Colors.white),
                    ),
                    SizedBox(
                      height: 10,
                    ),
                    Text(
                      widget.item.title.toUpperCase(),
                      style: TextStyle(fontSize: 24, color: Colors.white),
                      textAlign: TextAlign.center,
                    ),
                  ],
                ),
              )
            ],
          ),
        );
      }
    }
    

    snapping_list_view.dart

    import "package:flutter/widgets.dart";
    import "dart:math";
    
    class SnappingListView extends StatefulWidget {
      final Axis scrollDirection;
      final ScrollController controller;
    
      final IndexedWidgetBuilder itemBuilder;
      final List<Widget> children;
      final int itemCount;
    
      final double snapToInterval;
      final ValueChanged<int> onItemChanged;
    
      final EdgeInsets padding;
    
      SnappingListView(
          {this.scrollDirection,
            this.controller,
            @required this.children,
            @required this.snapToInterval,
            this.onItemChanged,
            this.padding = const EdgeInsets.all(0.0)})
          : assert(snapToInterval > 0),
            itemCount = null,
            itemBuilder = null;
    
      SnappingListView.builder(
          {this.scrollDirection,
            this.controller,
            @required this.itemBuilder,
            this.itemCount,
            @required this.snapToInterval,
            this.onItemChanged,
            this.padding = const EdgeInsets.all(0.0)})
          : assert(snapToInterval > 0),
            children = null;
    
      @override
      createState() => _SnappingListViewState();
    }
    
    class _SnappingListViewState extends State<SnappingListView> {
      int _lastItem = 0;
    
      @override
      Widget build(BuildContext context) {
        final startPadding = widget.scrollDirection == Axis.horizontal
            ? widget.padding.left
            : widget.padding.top;
        final scrollPhysics = SnappingListScrollPhysics(
            mainAxisStartPadding: startPadding, itemExtent: widget.snapToInterval);
        final listView = widget.children != null
            ? ListView(
            scrollDirection: widget.scrollDirection,
            controller: widget.controller,
            children: widget.children,
    
            physics: scrollPhysics,
            padding: widget.padding)
            : ListView.builder(
            scrollDirection: widget.scrollDirection,
            controller: widget.controller,
            itemBuilder: widget.itemBuilder,
            itemCount: widget.itemCount,
    
            physics: scrollPhysics,
            padding: widget.padding);
        return NotificationListener<ScrollNotification>(
            child: listView,
            onNotification: (notif) {
              if (notif.depth == 0 &&
                  widget.onItemChanged != null &&
                  notif is ScrollUpdateNotification) {
                final currItem =
                    (notif.metrics.pixels - startPadding) ~/ widget.snapToInterval;
                if (currItem != _lastItem) {
                  _lastItem = currItem;
                  widget.onItemChanged(currItem);
                }
              }
              return false;
            });
      }
    }
    
    class SnappingListScrollPhysics extends ScrollPhysics {
      final double mainAxisStartPadding;
      final double itemExtent;
    
      const SnappingListScrollPhysics(
          {ScrollPhysics parent,
            this.mainAxisStartPadding = 0.0,
            @required this.itemExtent})
          : super(parent: parent);
    
      @override
      SnappingListScrollPhysics applyTo(ScrollPhysics ancestor) {
        return SnappingListScrollPhysics(
            parent: buildParent(ancestor),
            mainAxisStartPadding: mainAxisStartPadding,
            itemExtent: itemExtent);
      }
    
      double _getItem(ScrollPosition position) {
        return (position.pixels - mainAxisStartPadding) / itemExtent;
      }
    
      double _getPixels(ScrollPosition position, double item) {
        return min(item * itemExtent, position.maxScrollExtent);
      }
    
      double _getTargetPixels(
          ScrollPosition position, Tolerance tolerance, double velocity) {
        double item = _getItem(position);
        if (velocity < -tolerance.velocity)
          item -= 0.5;
        else if (velocity > tolerance.velocity) item += 0.5;
        return _getPixels(position, item.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;
    }