mponkin / fading_edge_scrollview

Flutter library for displaying fading edges on scroll views
BSD 3-Clause "New" or "Revised" License
43 stars 30 forks source link

Can you add a small transition to the fade? #25

Open appleslize opened 10 months ago

appleslize commented 10 months ago

It would be nice if the fade at the bottom doesn't disappear so sudden. Maybe it could smoothly disappear when you are scrolling towards the end / start of the list?

FluffyDiscord commented 4 months ago

Would be really appreciated!

FluffyDiscord commented 4 months ago

I have added fade animation, should be a drop-in-replacement (also added prop to change gradient color, because, you know, white isn't always what you want). Feel free to PR and merge.

New optional parameters:

  /// What color should the fade be.
  /// 
  /// Default is [Colors.white]
  late final Color gradientColor;

  /// Fade transition duration.
  ///
  /// Default is 200ms
  late final Duration? duration;

  /// Fade transition curve.
  ///
  /// Default is [Curves.easeInOutSine]
  late final Curve curve;

https://github.com/mponkin/fading_edge_scrollview/assets/10332247/b7f353ea-e2ad-4f28-88cf-4b83511c1b59

import 'package:flutter/material.dart';

class AnimatedFadingEdgeScrollView extends StatefulWidget {
  /// child widget
  final Widget child;

  /// What color should the fade be.
  /// 
  /// Default is [Colors.white]
  late final Color gradientColor;

  /// Fade transition duration.
  ///
  /// Default is 200ms
  late final Duration? duration;

  /// Fade transition curve.
  ///
  /// Default is [Curves.easeInOutSine]
  late final Curve curve;

  /// scroll controller of child widget
  ///
  /// Look for more documentation at [ScrollView.scrollController]
  final ScrollController scrollController;

  /// Whether the scroll view scrolls in the reading direction.
  ///
  /// Look for more documentation at [ScrollView.reverse]
  final bool reverse;

  /// The axis along which child view scrolls
  ///
  /// Look for more documentation at [ScrollView.scrollDirection]
  final Axis scrollDirection;

  /// what part of screen on start half should be covered by fading edge gradient
  /// [gradientFractionOnStart] must be 0 <= [gradientFractionOnStart] <= 1
  /// 0 means no gradient,
  /// 1 means gradients on start half of widget fully covers it
  final double gradientFractionOnStart;

  /// what part of screen on end half should be covered by fading edge gradient
  /// [gradientFractionOnEnd] must be 0 <= [gradientFractionOnEnd] <= 1
  /// 0 means no gradient,
  /// 1 means gradients on start half of widget fully covers it
  final double gradientFractionOnEnd;

  AnimatedFadingEdgeScrollView._internal({
    super.key,
    required this.child,
    required this.reverse,
    required this.scrollController,
    required this.scrollDirection,
    required this.gradientFractionOnStart,
    required this.gradientFractionOnEnd,
    Color? gradientColor,
    Duration? duration,
    Curve? curve,
  }) {
    assert(gradientFractionOnStart >= 0 && gradientFractionOnStart <= 1);
    assert(gradientFractionOnEnd >= 0 && gradientFractionOnEnd <= 1);

    this.gradientColor = gradientColor ?? Colors.white;
    this.duration = duration ?? const Duration(milliseconds: 200);
    this.curve = curve ?? Curves.easeInOutSine;
  }

  /// Constructor for creating [AnimatedFadingEdgeScrollView] with [ScrollView] as child
  /// child must have [ScrollView.controller] set
  factory AnimatedFadingEdgeScrollView.fromScrollView({
    Key? key,
    required ScrollView child,
    double gradientFractionOnStart = 0.1,
    double gradientFractionOnEnd = 0.1,
    Color? gradientColor,
    Duration? duration,
    Curve? curve,
  }) {
    final controller = child.controller;
    if (controller == null) {
      throw Exception("Child must have controller set");
    }

    return AnimatedFadingEdgeScrollView._internal(
      key: key,
      scrollController: controller,
      scrollDirection: child.scrollDirection,
      reverse: child.reverse,
      gradientFractionOnStart: gradientFractionOnStart,
      gradientFractionOnEnd: gradientFractionOnEnd,
      gradientColor: gradientColor,
      duration: duration,
      curve: curve,
      child: child,
    );
  }

  /// Constructor for creating [AnimatedFadingEdgeScrollView] with [SingleChildScrollView] as child
  /// child must have [SingleChildScrollView.controller] set
  factory AnimatedFadingEdgeScrollView.fromSingleChildScrollView({
    Key? key,
    required SingleChildScrollView child,
    double gradientFractionOnStart = 0.1,
    double gradientFractionOnEnd = 0.1,
    Color? gradientColor,
    Duration? duration,
    Curve? curve,
  }) {
    final controller = child.controller;
    if (controller == null) {
      throw Exception("Child must have controller set");
    }

    return AnimatedFadingEdgeScrollView._internal(
      key: key,
      scrollController: controller,
      scrollDirection: child.scrollDirection,
      reverse: child.reverse,
      gradientFractionOnStart: gradientFractionOnStart,
      gradientFractionOnEnd: gradientFractionOnEnd,
      gradientColor: gradientColor,
      duration: duration,
      curve: curve,
      child: child,
    );
  }

  /// Constructor for creating [AnimatedFadingEdgeScrollView] with [PageView] as child
  /// child must have [PageView.controller] set
  factory AnimatedFadingEdgeScrollView.fromPageView({
    Key? key,
    required PageView child,
    double gradientFractionOnStart = 0.1,
    double gradientFractionOnEnd = 0.1,
    Color? gradientColor,
    Duration? duration,
    Curve? curve,
  }) {
    final controller = child.controller;
    //ignore: unnecessary_null_comparison
    if (controller == null) {
      throw Exception("Child must have controller set");
    }

    return AnimatedFadingEdgeScrollView._internal(
      key: key,
      scrollController: controller,
      scrollDirection: child.scrollDirection,
      reverse: child.reverse,
      gradientFractionOnStart: gradientFractionOnStart,
      gradientFractionOnEnd: gradientFractionOnEnd,
      gradientColor: gradientColor,
      duration: duration,
      curve: curve,
      child: child,
    );
  }

  /// Constructor for creating [AnimatedFadingEdgeScrollView] with [AnimatedList] as child
  /// child must have [AnimatedList.controller] set
  factory AnimatedFadingEdgeScrollView.fromAnimatedList({
    Key? key,
    required AnimatedList child,
    double gradientFractionOnStart = 0.1,
    double gradientFractionOnEnd = 0.1,
    Color? gradientColor,
    Duration? duration,
    Curve? curve,
  }) {
    final controller = child.controller;
    if (controller == null) {
      throw Exception("Child must have controller set");
    }

    return AnimatedFadingEdgeScrollView._internal(
      key: key,
      scrollController: controller,
      scrollDirection: child.scrollDirection,
      reverse: child.reverse,
      gradientFractionOnStart: gradientFractionOnStart,
      gradientFractionOnEnd: gradientFractionOnEnd,
      gradientColor: gradientColor,
      duration: duration,
      curve: curve,
      child: child,
    );
  }

  /// Constructor for creating [AnimatedFadingEdgeScrollView] with [ScrollView] as child
  /// child must have [ScrollView.controller] set
  factory AnimatedFadingEdgeScrollView.fromListWheelScrollView({
    Key? key,
    required ListWheelScrollView child,
    double gradientFractionOnStart = 0.1,
    double gradientFractionOnEnd = 0.1,
    Color? gradientColor,
    Duration? duration,
    Curve? curve,
  }) {
    final controller = child.controller;
    if (controller == null) {
      throw Exception("Child must have controller set");
    }

    return AnimatedFadingEdgeScrollView._internal(
      key: key,
      scrollController: controller,
      scrollDirection: Axis.vertical,
      reverse: false,
      gradientFractionOnStart: gradientFractionOnStart,
      gradientFractionOnEnd: gradientFractionOnEnd,
      gradientColor: gradientColor,
      duration: duration,
      curve: curve,
      child: child,
    );
  }

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

class AnimatedFadingEdgeScrollViewState extends State<AnimatedFadingEdgeScrollView> with TickerProviderStateMixin, WidgetsBindingObserver {
  AnimationController? _topAnimationController;
  AnimationController? _bottomAnimationController;
  Animation<double>? _topFade;
  Animation<double>? _bottomFade;

  @override
  void initState() {
    super.initState();

    _topAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
    );

    _bottomAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
    );

    CurvedAnimation topCurve = CurvedAnimation(parent: _topAnimationController!, curve: Curves.easeInOutSine);
    CurvedAnimation bottomCurve = CurvedAnimation(parent: _bottomAnimationController!, curve: Curves.easeInOutSine);

    _topFade = Tween<double>(begin: 1.0, end: 0.0).animate(topCurve)
      ..addListener(() {
        setState(() {});
      });

    _bottomFade = Tween<double>(begin: 1.0, end: 0.0).animate(bottomCurve)
      ..addListener(() {
        setState(() {});
      });

    widget.scrollController.addListener(_updateFade);

    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeMetrics() {
    super.didChangeMetrics();
    // Add the shading or remove it when the screen resize (web/desktop) or mobile is rotated
    _updateFade();
  }

  bool get _controllerIsReady => widget.scrollController.hasClients && widget.scrollController.positions.last.hasContentDimensions;

  @override
  void dispose() {
    _topAnimationController?.dispose();
    _bottomAnimationController?.dispose();
    widget.scrollController.removeListener(_updateFade);
    WidgetsBinding.instance.removeObserver(this);

    super.dispose();
  }

  void _updateFade() {
    if(!_controllerIsReady) {
      return;
    }

    if(!_topAnimationController!.isAnimating) {
      if (widget.scrollController.offset > 0 && _topFade!.value == 1.0) {
        _topAnimationController!.forward();
      } else if (widget.scrollController.offset <= 0 && _topFade!.value != 1.0) {
        _topAnimationController!.reverse();
      }
    }

    if(!_bottomAnimationController!.isAnimating) {
      if (widget.scrollController.offset < widget.scrollController.position.maxScrollExtent - 0 && _bottomFade!.value == 1.0) {
        _bottomAnimationController!.forward();
      } else if (widget.scrollController.offset >= widget.scrollController.position.maxScrollExtent - 0 && _bottomFade!.value != 1.0) {
        _bottomAnimationController!.reverse();
      }
    }
  }

  Gradient _createShaderGradient() => LinearGradient(
    begin: _gradientStart,
    end: _gradientEnd,
    stops: [
      0,
      widget.gradientFractionOnStart * 0.5,
      1 - widget.gradientFractionOnEnd * 0.5,
      1,
    ],
    colors: [
      Colors.transparent.withOpacity(_topFade!.value),
      widget.gradientColor,
      widget.gradientColor,
      Colors.transparent.withOpacity(_bottomFade!.value)
    ],
  );

  AlignmentGeometry get _gradientStart =>
      widget.scrollDirection == Axis.vertical
          ? _verticalStart
          : _horizontalStart;

  AlignmentGeometry get _gradientEnd =>
      widget.scrollDirection == Axis.vertical ? _verticalEnd : _horizontalEnd;

  Alignment get _verticalStart =>
      widget.reverse ? Alignment.bottomCenter : Alignment.topCenter;

  Alignment get _verticalEnd =>
      widget.reverse ? Alignment.topCenter : Alignment.bottomCenter;

  AlignmentDirectional get _horizontalStart => widget.reverse
      ? AlignmentDirectional.centerEnd
      : AlignmentDirectional.centerStart;

  AlignmentDirectional get _horizontalEnd => widget.reverse
      ? AlignmentDirectional.centerStart
      : AlignmentDirectional.centerEnd;

  @override
  Widget build(BuildContext context) {
    return ShaderMask(
      shaderCallback: (bounds) => _createShaderGradient().createShader(
        bounds.shift(Offset(-bounds.left, -bounds.top)),
        textDirection: Directionality.of(context),
      ),
      blendMode: BlendMode.dstIn,
      child: NotificationListener<ScrollMetricsNotification>(
        child: widget.child,
        onNotification: (_) {
          _updateFade();
          // Enable notification to still bubble up.
          return false;
        },
      ),
    );
  }
}