11

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.

Shaheen Zahedi
  • 1,216
  • 3
  • 15
  • 40

4 Answers4

4

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;
}
Trong Luong
  • 146
  • 2
  • 4
3

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,
        ),
      ),
    );
  }
}
Harry
  • 624
  • 5
  • 14
  • hey, thanks for the explanation but can you provide a working example, in the example above 'widgets' or empty. – Shaheen Zahedi Oct 16 '20 at 12:22
  • I'll put my main.dart that I tested it with – Harry Oct 16 '20 at 12:33
  • Thanks it was brilliant, but unfortunately the approach has some problems, 1- it doesn't work in big screens 2- it doesn't go further than a couple of items IDK why, but scrolling gets so slow after a couple of items' depending on screensize, anyway it was a strong approach, thanks and upvote. – Shaheen Zahedi Oct 19 '20 at 12:42
  • It doesn't go past the last few items due to there not being more list items. I haven't exactly used the Channel app so I don't know what happens when the last list items end. Maybe my example of lists in my main.dart was too short for big(tall) screens. It is likely able to be optimized by perhaps using ListView instead of SingleScrollView and Column. Slivers may be able to be used to optimise it as well due to them being able to unload. This is merely an example and not an optimised bugless widget that I created. – Harry Oct 19 '20 at 22:16
  • @Harry I need some help with my question here: https://stackoverflow.com/questions/69234567/fadetransition-while-using-pageview I'm trying to fade multiple widgets (i posted only 2, with another one is commented out). You're help will be much appreciated.. Thanks in advance.. – user10033434 Sep 19 '21 at 12:31
  • tried but cannot get through it with images – Nabin Dhakal Oct 06 '21 at 11:02
1

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.

Mohammed Alfateh
  • 3,186
  • 1
  • 10
  • 24
  • Already saw that one, first of all, remember mine has a parallax effect, second you can't resize children of a regular scroller after it's being drawn, third figuring out first visible child in a scroller is a big challenge itself – Shaheen Zahedi Oct 12 '20 at 20:29
  • Yeah basically the scroll offset will change when the child size change, I just tried to play around and get index by using condition (offset % widget height ==0 ) with some scroll direction but if you scroll fast this line would have no effects. I yet think there is a way to do it but finding it would take some time, weather calculating the index by the scroll offset and child Hight while considering the calculation of the increased widget size or anyway else. – Mohammed Alfateh Oct 13 '20 at 00:53
-1

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>[

          ],
        ),
      ),
    ],
  ),
);
moe safar
  • 140
  • 2
  • 17
  • it's not event close, first of all the header is not different from the list, exapnded view has a text animation, third when you set pinned='true' all the other views that are not expanded, will stick to the screen, which occupies the whole screen when widgets are too many, lot of other things – Shaheen Zahedi Oct 11 '20 at 06:53
  • I'm sorry you didn't find this helpful. I have put this as a working demo rather than implementing it in your code and I got it to work. In short, a `SilverList()` should do the trick – moe safar Oct 12 '20 at 07:03