implementing custom scrolling like Chanel app in flutter?

1k views Asked by At

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.

4

There are 4 answers

1
Trong Luong On BEST ANSWER

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;
}
2
moe safar On

try to use Sliver.

This is an example of what I mean:

body: CustomScrollView(
    slivers: <Widget>[
      SliverAppBar(
        backgroundColor: Color(0xFF0084C9),
        leading: IconButton(
          icon: Icon(
            Icons.blur_on,
            color: Colors.white70,
          ),
          onPressed: () {
            Scaffold.of(context).openDrawer();
          },
        ),
        expandedHeight: bannerHigh,
        floating: true,
        pinned: true,
        flexibleSpace: FlexibleSpaceBar(
          title: Text("Your title",
              style: TextStyle(
                  fontSize: 18,
                  color: Colors.white,
                  fontWeight: FontWeight.w600)),
          background: Image.network(
            'image url',
            fit: BoxFit.cover,
          ),
        ),
      ),
      SliverList(
        delegate: SliverChildListDelegate(
          <Widget>[

          ],
        ),
      ),
    ],
  ),
);
2
Mohammed Alfateh On

You can do that using ScrollController value to change the size of the widget or it's children's, sorry I can't write the code because it's time consuming and requires some computation but watch this video:https://www.youtube.com/watch?v=Cn6VCTaHB-k&t=558s it will gave you the basic idea and help you keep going.

6
Harry On

Use a with a SingleChildScrollView with a column as it's child. In order to make the picture small when it's a header, use a FittedBox. Wrap the FittedBox with a SizedBox to control the size of the inside widgets. Use a scroll notifier to cause updates when it is scrolling and track how far the user scrolls. Divide the scroll amount by the max height that you want in order to know the current widget that needs resizing. Resize that widget by finding the remainder and dividing it by the max height and multiplying by the difference of the min and max size then add min size. This will ensure a smooth transition. Then make any widgets above in the column max sized and below minimum sized to make sure lag doesn't ruin the scroller.

Use AnimatedOpacity to allow the description of the header to fade in and out or make a customized animation of how you think it should look.

The following code should work though customize the text widgets with what style you'd like. Enter the custom TitleWithImage(contains widget and two strings) items to be in the list, the maxHeight and minHeight into the custom widget. It likely isn't completely optimized and probably has lots of bugs although I fixed some:

import 'package:flutter/material.dart';

class CoolListView extends StatefulWidget {
  final List<TitleWithImage> items;
  final double minHeight;
  final double maxHeight;
  const CoolListView({Key key, this.items, this.minHeight, this.maxHeight}) : super(key: key);

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

class _CoolListViewState extends State<CoolListView> {
  List<Widget> widgets=[];
  ScrollController _scrollController = new ScrollController();
  @override
  Widget build(BuildContext context) {
    if(widgets.length == 0){
      for(int i = 0; i<widget.items.length; i++){
        if(i==0){
          widgets.add(ListItem(height: widget.maxHeight, item: widget.items[0],descriptionTransparent: false));
        }
        else{
          widgets.add(
            ListItem(height: widget.minHeight, item: widget.items[i], descriptionTransparent: true,)
          );
        }
      }
    }
    return new NotificationListener<ScrollUpdateNotification>(
      child: SingleChildScrollView(
        controller: _scrollController,
        child: Column(
          children: widgets,
        )
      ),
      onNotification: (t) {
        if (t!= null && t is ScrollUpdateNotification) {
          int currentWidget = (_scrollController.position.pixels/widget.maxHeight).ceil();
          currentWidget = currentWidget==-1?0:currentWidget;
          setState(() {
            if(currentWidget != widgets.length-1){//makes higher index min
              for(int i = currentWidget+1; i<=widgets.length-1; i++){
                print(i);
                widgets[i] = ListItem(height: widget.minHeight, item: widget.items[i],descriptionTransparent: true,);
              }
            }
            if(currentWidget!=0){
              widgets[currentWidget] = ListItem(
                height: _scrollController.position.pixels%widget.maxHeight/widget.maxHeight*(widget.maxHeight-widget.minHeight)+widget.minHeight,
                item: widget.items[currentWidget],
                descriptionTransparent: true,
              );
              for(int i = currentWidget-1; i>=0; i--){
                widgets[i] = ListItem(height: widget.maxHeight,
                  item: widget.items[i],
                  descriptionTransparent: false,
                );
              }
            }
            else{
              widgets[0] = ListItem(
                height: widget.maxHeight,
                item: widget.items[0],
                descriptionTransparent: false
              );
            }
          });
        }
      },
    );
  }

  
}
class TitleWithImage
{
  final Widget image;
  final String title;
  final String description;
  TitleWithImage(this.image, this.title, this.description);
}
class ListItem extends StatelessWidget {
  final double height;
  final TitleWithImage item;
  final bool descriptionTransparent;
  const ListItem({Key key, this.height, this.item, this.descriptionTransparent}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Container(
      child:Stack(
        children: [
          SizedBox(
            height: height,
            width: MediaQuery.of(context).size.width,
            child: FittedBox(
            fit: BoxFit.none,
            child:Align(
              alignment: Alignment.center,
              child: item.image
            )
            ),
          ),
          SizedBox(
            height: height,
            width: MediaQuery.of(context).size.width,
            child: Column(
              children: [
                Spacer(),
                Text(item.title,),
                AnimatedOpacity(
                  child: Text(
                    item.description,
                    style: TextStyle(
                      color: Colors.black
                    ),
                  ),
                  opacity: descriptionTransparent? 0.0 : 1.0,
                  duration: Duration(milliseconds: 500),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Edit here is my main.dart:

import 'package:cool_list_view/CoolListView.dart';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Collapsing List Demo')),
        body: CoolListView(
          items: [
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      const Color(0xffee0000),
                      const Color(0xffeeee00)
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      Colors.orange,
                      Colors.blue,
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      const Color(0xffee0000),
                      const Color(0xffeeee00)
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      const Color(0xffee0000),
                      const Color(0xffeeee00)
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      const Color(0xffee0000),
                      const Color(0xffeeee00)
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      const Color(0xffee0000),
                      const Color(0xffeeee00)
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(
              Container(
                height: 1000,
                width:1000,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.topLeft,
                    end:
                        Alignment(0.8, 0.0), // 10% of the width, so there are ten blinds.
                    colors: [
                      const Color(0xffee0000),
                      const Color(0xffeeee00)
                    ], // red to yellow
                    tileMode: TileMode.repeated, // repeats the gradient over the canvas
                  ),
                ),
              ),
              'title',
              'description',
            ),
            new TitleWithImage(Container(height: 1000,width:1000,color: Colors.blue), 'title', 'description'),
            new TitleWithImage(Container(height: 1000,width:1000, color: Colors.orange), 'title', 'description'),
          ],
          minHeight: 50,
          maxHeight: 300,
        ),
      ),
    );
  }
}