Search code examples
androidflutterdartlistviewflutter-animation

Fade effect on the entering and the exiting child of ListWheelScrollView


As it can be seen in the GIF below that whenever the child exits the parent Container the child disappears without animation, which creates a bad impression on the user. How to add a smooth transition exit for the entering and exiting child ?

Chat GPT gave an answer which seems to have the right logic but it gives the same abrupt effect.

Chat GPT Code : -

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('Smooth Transition in ListWheelScrollView')),
        body: TransitionListWheel(),
      ),
    );
  }
}

class TransitionListWheel extends StatefulWidget {
  @override
  _TransitionListWheelState createState() => _TransitionListWheelState();
}

class _TransitionListWheelState extends State<TransitionListWheel> {
  FixedExtentScrollController _scrollController;
  double itemHeight = 100.0; // Height of each item in the list
  int itemCount = 20;

  @override
  void initState() {
    super.initState();
    _scrollController = FixedExtentScrollController();
    _scrollController.addListener(_handleScroll);
  }

  @override
  void dispose() {
    _scrollController.removeListener(_handleScroll);
    _scrollController.dispose();
    super.dispose();
  }

  void _handleScroll() {
    setState(() {
      // Trigger a rebuild to update the transition animations
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        height: itemHeight * 5, // Visible height of the list
        child: ListWheelScrollView.useDelegate(
          controller: _scrollController,
          itemExtent: itemHeight,
          physics: FixedExtentScrollPhysics(),
          childDelegate: ListWheelChildBuilderDelegate(
            builder: (context, index) {
              final double scrollOffset = _scrollController.offset;
              final double itemScrollOffset = scrollOffset % itemHeight;

              // Calculate transition values for entering and exiting animations
              final double enteringScale = 1.0 - (itemScrollOffset / itemHeight);
              final double exitingScale = itemScrollOffset / itemHeight;

              return AnimatedBuilder(
                animation: _scrollController,
                builder: (context, child) {
                  return Transform.scale(
                    scale: (enteringScale + exitingScale).clamp(0.6, 1.0), // Adjust the range as needed
                    child: child,
                  );
                },
                child: Center(
                  child: Container(
                    width: 200,
                    height: 80,
                    color: Colors.blue,
                    child: Center(
                      child: Text(
                        'Item $index',
                        style: TextStyle(color: Colors.white),
                      ),
                    ),
                  ),
                ),
              );
            },
            childCount: itemCount,
          ),
        ),
      ),
    );
  }
}

Code which I use : -

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'List',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: const List(),
    );
  }
}

class List extends StatefulWidget {
  const List({Key? key}) : super(key: key);
  @override
  _ListState createState() => _ListState();
}

class _ListState extends State<List> {
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
       
        body: Center(
          child: SizedBox(
            height: 500,
            child: ListWheelScrollView(
                itemExtent: 100,
                physics: const FixedExtentScrollPhysics(),
                onSelectedItemChanged: (value) {
                  
                },
                children: [
                  for (int i = 0; i < 5; i++) ...[
                    Container(
                      color: Colors.green,
                      height: 50,
                      width: 50,
                    )
                  ]
                ]),
          ),
        ));
  }
}

Solution

  • I extended the ChatGPT code, try it:

    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('Smooth Transition in ListWheelScrollView')),
            body: TransitionListWheel(),
          ),
        );
      }
    }
    
    class TransitionListWheel extends StatefulWidget {
      @override
      _TransitionListWheelState createState() => _TransitionListWheelState();
    }
    
    class _TransitionListWheelState extends State<TransitionListWheel> {
      late final FixedExtentScrollController _scrollController;
      double itemHeight = 100.0; // Height of each item in the list
      int itemCount = 20;
      int viewCount = 5;
    
      @override
      void initState() {
        super.initState();
        _scrollController = FixedExtentScrollController();
        _scrollController.addListener(_handleScroll);
      }
    
      @override
      void dispose() {
        _scrollController.removeListener(_handleScroll);
        _scrollController.dispose();
        super.dispose();
      }
    
      void _handleScroll() {
        setState(() {
          // Trigger a rebuild to update the transition animations
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Center(
          child: Container(
            height: itemHeight * viewCount, // Visible height of the list
            child: ListWheelScrollView.useDelegate(
              controller: _scrollController,
              itemExtent: itemHeight,
              physics: FixedExtentScrollPhysics(),
              childDelegate: ListWheelChildBuilderDelegate(
                builder: (context, index) {
                  final double scrollOffset = _scrollController.offset;
                  final double itemScrollOffset = scrollOffset % itemHeight;
    
                  // Calculate transition values for entering and exiting animations
                  final double enteringScale =
                      1.0 - (itemScrollOffset / itemHeight);
                  final double exitingScale = itemScrollOffset / itemHeight;
    
                  final centerOffset = index * itemHeight - scrollOffset;
                  final itemOpacity = (double x) {
                    if (x < viewCount ~/ 2 * itemHeight) return 1.0;
                    if (x > (viewCount ~/ 2 + 1) * itemHeight) return 0.0;
                    return 1 - (x - viewCount ~/ 2 * itemHeight) / itemHeight;
                  }(centerOffset.abs() + 25); // adjust as needed
    
                  return AnimatedBuilder(
                    animation: _scrollController,
                    builder: (context, child) {
                      // return Text('offset: $centerOffset,  opacity: $itemOpacity');
                      return Transform.scale(
                        scale: (enteringScale + exitingScale)
                            .clamp(0.6, 1.0), // Adjust the range as needed
                        child: Opacity(opacity: itemOpacity, child: child),
                      );
                    },
                    child: Center(
                      child: Container(
                        width: 200,
                        height: 80,
                        color: Colors.blue,
                        child: Center(
                          child: Text(
                            'Item $index',
                            style: TextStyle(color: Colors.white),
                          ),
                        ),
                      ),
                    ),
                  );
                },
                childCount: itemCount,
              ),
            ),
          ),
        );
      }
    }
    

    Edit: this is how it works

    this line calculates the offset from the center for each index. meaning if the index in the center, the offset well be 0, negative if index in the top, positive if index in the bottom. kinda like (-y) axis.

    final centerOffset = index * itemHeight - scrollOffset;
    

    we can it's absolute value to tell how far an item from the center let's say we have 5 items that are visible, meaning Container height will be 500, hence the two edge items will have centerOffset of value (-200 and 200) we can do a switch case logic like this:

    if centerOffset less then 200 (item is close to the center) then opacity will be 1

    if centerOffset more then 200 + itemHeight (not in the visible area) opacity will be 0

    else (item is close to edge, between 200 and 300)

    1. first we subtract the 200 to have a variable between 0 and 100 (x - viewCount ~/ 2 * itemHeight)
    2. we divide that variable by 100 itemHeight (sorry I hard coded 100 in previous answer it should be itemHeight) to make between 0 and 1
    3. we don't want a value between 0 and, we need a value between 1 and 0, so we apply 1 - x to the value

    all the previous numbers are tied to height 500 (viewCount), that's why I used viewCount ~/ 2

    uncomment the Text in the builder to visualize what I just explained

    Note: if you don't like linear opacity you can also transform it

    final transformedOpacity = Curves.fastEaseInToSlowEaseOut.transform(itemOpacity);