diff --git a/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart b/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart index 55141f12ba..8d4364573d 100644 --- a/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart +++ b/mobile/apps/photos/lib/ui/viewer/gallery/gallery.dart @@ -17,8 +17,8 @@ import 'package:photos/ui/common/loading_widget.dart'; import "package:photos/ui/viewer/gallery/component/group/group_header_widget.dart"; import "package:photos/ui/viewer/gallery/component/group/type.dart"; import "package:photos/ui/viewer/gallery/component/sectioned_sliver_list.dart"; -import "package:photos/ui/viewer/gallery/custom_scroll_bar.dart"; import 'package:photos/ui/viewer/gallery/empty_state.dart'; +import "package:photos/ui/viewer/gallery/scrollbar/custom_scroll_bar_2.dart"; import "package:photos/ui/viewer/gallery/state/gallery_context_state.dart"; import "package:photos/ui/viewer/gallery/state/gallery_files_inherited_widget.dart"; import "package:photos/ui/viewer/gallery/state/inherited_search_filter_data.dart"; @@ -477,9 +477,8 @@ class GalleryState extends State { // widget.showSelectAllByDefault && widget.groupType.showGroupHeader(), // isScrollablePositionedList: widget.isScrollablePositionedList, // ), - child: CustomScrollBar( + child: CustomScrollBar2( scrollController: _scrollController, - galleryGroups: galleryGroups, child: Stack( key: _stackKey, clipBehavior: Clip.none, diff --git a/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/cupertino_scroll_bar_with_use_notifier.dart b/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/cupertino_scroll_bar_with_use_notifier.dart new file mode 100644 index 0000000000..c6a944f325 --- /dev/null +++ b/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/cupertino_scroll_bar_with_use_notifier.dart @@ -0,0 +1,234 @@ +// Modified CupertinoScrollbar that accepts a ValueNotifier to indicate +// if the scrollbar is in use. In flutter CupertinoScrollbar is in a different +// file where as MaterialScrollbar is in the same file as ScrollBar. So +// following the same convention by create a separate file for +// CupertinoScrollbarWithUseNotifier. + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import "package:flutter/cupertino.dart"; +import 'package:flutter/services.dart'; + +// All values eyeballed. +const double _kScrollbarMinLength = 36.0; +const double _kScrollbarMinOverscrollLength = 8.0; +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); + +// Extracted from iOS 13.1 beta using Debug View Hierarchy. +const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: Color(0x59000000), + darkColor: Color(0x80FFFFFF), +); + +// This is the amount of space from the top of a vertical scrollbar to the +// top edge of the scrollable, measured when the vertical scrollbar overscrolls +// to the top. +// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 3.0; + +/// An iOS style scrollbar. +/// +/// To add a scrollbar to a [ScrollView], wrap the scroll view widget in +/// a [CupertinoScrollbarWithUseNotifier] widget. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc} +/// +/// {@macro flutter.widgets.Scrollbar} +/// +/// When dragging a [CupertinoScrollbarWithUseNotifier] thumb, the thickness and radius will +/// animate from [thickness] and [radius] to [thicknessWhileDragging] and +/// [radiusWhileDragging], respectively. +/// +/// {@tool dartpad} +/// This sample shows a [CupertinoScrollbarWithUseNotifier] that fades in and out of view as scrolling occurs. +/// The scrollbar will fade into view as the user scrolls, and fade out when scrolling stops. +/// The `thickness` of the scrollbar will animate from 6 pixels to the `thicknessWhileDragging` of 10 +/// when it is dragged by the user. The `radius` of the scrollbar thumb corners will animate from 34 +/// to the `radiusWhileDragging` of 0 when the scrollbar is being dragged by the user. +/// +/// ** See code in examples/api/lib/cupertino/scrollbar/cupertino_scrollbar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// When [thumbVisibility] is true, the scrollbar thumb will remain visible without the +/// fade animation. This requires that a [ScrollController] is provided to controller, +/// or that the [PrimaryScrollController] is available. +/// +/// ** See code in examples/api/lib/cupertino/scrollbar/cupertino_scrollbar.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ListView], which displays a linear, scrollable list of children. +/// * [GridView], which displays a 2 dimensional, scrollable array of children. +/// * [Scrollbar], a Material Design scrollbar. +/// * [RawScrollbar], a basic scrollbar that fades in and out, extended +/// by this class to add more animations and behaviors. +class CupertinoScrollbarWithUseNotifier extends RawScrollbar { + /// Creates an iOS style scrollbar that wraps the given [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + + final ValueNotifier inUseNotifier; + const CupertinoScrollbarWithUseNotifier({ + super.key, + required super.child, + required this.inUseNotifier, + super.controller, + bool? thumbVisibility, + double super.thickness = defaultThickness, + this.thicknessWhileDragging = defaultThicknessWhileDragging, + Radius super.radius = defaultRadius, + this.radiusWhileDragging = defaultRadiusWhileDragging, + ScrollNotificationPredicate? notificationPredicate, + super.scrollbarOrientation, + }) : assert(thickness < double.infinity), + assert(thicknessWhileDragging < double.infinity), + super( + thumbVisibility: thumbVisibility ?? false, + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + pressDuration: const Duration(milliseconds: 100), + notificationPredicate: + notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + /// Default value for [thickness] if it's not specified in [CupertinoScrollbarWithUseNotifier]. + static const double defaultThickness = 3; + + /// Default value for [thicknessWhileDragging] if it's not specified in + /// [CupertinoScrollbarWithUseNotifier]. + static const double defaultThicknessWhileDragging = 8.0; + + /// Default value for [radius] if it's not specified in [CupertinoScrollbarWithUseNotifier]. + static const Radius defaultRadius = Radius.circular(1.5); + + /// Default value for [radiusWhileDragging] if it's not specified in + /// [CupertinoScrollbarWithUseNotifier]. + static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); + + /// The thickness of the scrollbar when it's being dragged by the user. + /// + /// When the user starts dragging the scrollbar, the thickness will animate + /// from [thickness] to this value, then animate back when the user stops + /// dragging the scrollbar. + final double thicknessWhileDragging; + + /// The radius of the scrollbar edges when the scrollbar is being dragged by + /// the user. + /// + /// When the user starts dragging the scrollbar, the radius will animate + /// from [radius] to this value, then animate back when the user stops + /// dragging the scrollbar. + final Radius radiusWhileDragging; + + @override + RawScrollbarState createState() => + _CupertinoScrollbarState(); +} + +class _CupertinoScrollbarState + extends RawScrollbarState { + late AnimationController _thicknessAnimationController; + + double get _thickness { + return widget.thickness! + + _thicknessAnimationController.value * + (widget.thicknessWhileDragging - widget.thickness!); + } + + Radius get _radius { + return Radius.lerp( + widget.radius, + widget.radiusWhileDragging, + _thicknessAnimationController.value, + )!; + } + + @override + void initState() { + super.initState(); + _thicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _thicknessAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context) + ..textDirection = Directionality.of(context) + ..thickness = _thickness + ..mainAxisMargin = _kScrollbarMainAxisMargin + ..crossAxisMargin = _kScrollbarCrossAxisMargin + ..radius = _radius + ..padding = MediaQuery.paddingOf(context) + ..minLength = _kScrollbarMinLength + ..minOverscrollLength = _kScrollbarMinOverscrollLength + ..scrollbarOrientation = widget.scrollbarOrientation; + } + + double _pressStartAxisPosition = 0.0; + + // Long press event callbacks handle the gesture where the user long presses + // on the scrollbar thumb and then drags the scrollbar without releasing. + + @override + void handleThumbPressStart(Offset localPosition) { + super.handleThumbPressStart(localPosition); + widget.inUseNotifier.value = true; + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _pressStartAxisPosition = switch (direction) { + Axis.vertical => localPosition.dy, + Axis.horizontal => localPosition.dx, + }; + } + + @override + void handleThumbPress() { + if (getScrollbarDirection() == null) { + return; + } + super.handleThumbPress(); + _thicknessAnimationController.forward().then( + (_) => HapticFeedback.mediumImpact(), + ); + } + + @override + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + widget.inUseNotifier.value = false; + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _thicknessAnimationController.reverse(); + super.handleThumbPressEnd(localPosition, velocity); + final (double axisPosition, double axisVelocity) = switch (direction) { + Axis.horizontal => (localPosition.dx, velocity.pixelsPerSecond.dx), + Axis.vertical => (localPosition.dy, velocity.pixelsPerSecond.dy), + }; + if (axisPosition != _pressStartAxisPosition && axisVelocity.abs() < 10) { + HapticFeedback.mediumImpact(); + } + } + + @override + void dispose() { + _thicknessAnimationController.dispose(); + super.dispose(); + } +} diff --git a/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/custom_scroll_bar_2.dart b/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/custom_scroll_bar_2.dart new file mode 100644 index 0000000000..968196013d --- /dev/null +++ b/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/custom_scroll_bar_2.dart @@ -0,0 +1,102 @@ +import "package:flutter/material.dart"; +import "package:photos/ui/viewer/gallery/scrollbar/scroll_bar_with_use_notifier.dart"; + +class CustomScrollBar2 extends StatefulWidget { + final Widget child; + final ScrollController scrollController; + const CustomScrollBar2({ + super.key, + required this.child, + required this.scrollController, + }); + + @override + State createState() => _CustomScrollBar2State(); +} + +class _CustomScrollBar2State extends State { + final inUseNotifier = ValueNotifier(false); + @override + Widget build(BuildContext context) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + ScrollbarWithUseNotifer( + controller: widget.scrollController, + interactive: true, + inUseNotifier: inUseNotifier, + child: widget.child, + ), + ValueListenableBuilder( + valueListenable: inUseNotifier, + builder: (context, inUse, _) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + child: !inUse + ? const SizedBox.shrink() + : Stack( + children: [ + Positioned( + top: 50, + right: 24, + child: Container( + color: Colors.teal, + child: const Center( + child: Text( + 'Item 1', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + Positioned( + top: 100, + right: 24, + child: Container( + color: Colors.teal, + child: const Center( + child: Text( + 'Item 2', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + Positioned( + top: 250, + right: 24, + child: Container( + color: Colors.teal, + child: const Center( + child: Text( + 'Item 3', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + Positioned( + top: 400, + right: 24, + child: Container( + color: Colors.teal, + child: const Center( + child: Text( + 'Item 4', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ], + ); + } +} diff --git a/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/scroll_bar_with_use_notifier.dart b/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/scroll_bar_with_use_notifier.dart new file mode 100644 index 0000000000..282e331510 --- /dev/null +++ b/mobile/apps/photos/lib/ui/viewer/gallery/scrollbar/scroll_bar_with_use_notifier.dart @@ -0,0 +1,433 @@ +// Modified Scrollbar that accepts a ValueNotifier to indicate if the +// scrollbar is in use + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import "package:flutter/material.dart"; +import "package:photos/ui/viewer/gallery/scrollbar/cupertino_scroll_bar_with_use_notifier.dart"; + +const double _kScrollbarThickness = 8.0; +const double _kScrollbarThicknessWithTrack = 12.0; +const double _kScrollbarMargin = 2.0; +const double _kScrollbarMinLength = 48.0; +const Radius _kScrollbarRadius = Radius.circular(8.0); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); + +/// A Material Design scrollbar. +/// +/// To add a scrollbar to a [ScrollView], wrap the scroll view +/// widget in a [ScrollbarWithUseNotifer] widget. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc} +/// +/// {@macro flutter.widgets.Scrollbar} +/// +/// Dynamically changes to a [CupertinoScrollbar], an iOS style scrollbar, by +/// default on the iOS platform. +/// +/// The color of the Scrollbar thumb will change when [MaterialState.dragged], +/// or [MaterialState.hovered] on desktop and web platforms. These stateful +/// color choices can be changed using [ScrollbarThemeData.thumbColor]. +/// +/// {@tool dartpad} +/// This sample shows a [ScrollbarWithUseNotifer] that executes a fade animation as scrolling +/// occurs. The Scrollbar will fade into view as the user scrolls, and fade out +/// when scrolling stops. +/// +/// ** See code in examples/api/lib/material/scrollbar/scrollbar.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// When [thumbVisibility] is true, the scrollbar thumb will remain visible +/// without the fade animation. This requires that a [ScrollController] is +/// provided to controller, or that the [PrimaryScrollController] is available. +/// +/// When a [ScrollView.scrollDirection] is [Axis.horizontal], it is recommended +/// that the [ScrollbarWithUseNotifer] is always visible, since scrolling in the horizontal +/// axis is less discoverable. +/// +/// ** See code in examples/api/lib/material/scrollbar/scrollbar.1.dart ** +/// {@end-tool} +/// +/// A scrollbar track can be added using [trackVisibility]. This can also be +/// drawn when triggered by a hover event, or based on any [MaterialState] by +/// using [ScrollbarThemeData.trackVisibility]. +/// +/// The [thickness] of the track and scrollbar thumb can be changed dynamically +/// in response to [MaterialState]s using [ScrollbarThemeData.thickness]. +/// +/// See also: +/// +/// * [RawScrollbar], a basic scrollbar that fades in and out, extended +/// by this class to add more animations and behaviors. +/// * [ScrollbarTheme], which configures the Scrollbar's appearance. +/// * [CupertinoScrollbar], an iOS style scrollbar. +/// * [ListView], which displays a linear, scrollable list of children. +/// * [GridView], which displays a 2 dimensional, scrollable array of children. +class ScrollbarWithUseNotifer extends StatelessWidget { + /// Creates a Material Design scrollbar that by default will connect to the + /// closest Scrollable descendant of [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + /// + /// If the [controller] is null, the default behavior is to + /// enable scrollbar dragging using the [PrimaryScrollController]. + /// + /// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0 + /// pixels when on mobile platforms. A null [radius] will result in a default + /// of an 8.0 pixel circular radius about the corners of the scrollbar thumb, + /// except for when executing on [TargetPlatform.android], which will render the + /// thumb without a radius. + const ScrollbarWithUseNotifer({ + super.key, + required this.child, + required this.inUseNotifier, + this.controller, + this.thumbVisibility, + this.trackVisibility, + this.thickness, + this.radius, + this.notificationPredicate, + this.interactive, + this.scrollbarOrientation, + }); + + /// {@macro flutter.widgets.Scrollbar.child} + final Widget child; + + /// {@macro flutter.widgets.Scrollbar.controller} + final ScrollController? controller; + + /// {@macro flutter.widgets.Scrollbar.thumbVisibility} + /// + /// If this property is null, then [ScrollbarThemeData.thumbVisibility] of + /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value + /// is false. + /// + /// If the thumb visibility is related to the scrollbar's material state, + /// use the global [ScrollbarThemeData.thumbVisibility] or override the + /// sub-tree's theme data. + final bool? thumbVisibility; + + /// {@macro flutter.widgets.Scrollbar.trackVisibility} + /// + /// If this property is null, then [ScrollbarThemeData.trackVisibility] of + /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value + /// is false. + /// + /// If the track visibility is related to the scrollbar's material state, + /// use the global [ScrollbarThemeData.trackVisibility] or override the + /// sub-tree's theme data. + final bool? trackVisibility; + + /// The thickness of the scrollbar in the cross axis of the scrollable. + /// + /// If null, the default value is platform dependent. On [TargetPlatform.android], + /// the default thickness is 4.0 pixels. On [TargetPlatform.iOS], + /// [CupertinoScrollbar.defaultThickness] is used. The remaining platforms have a + /// default thickness of 8.0 pixels. + final double? thickness; + + /// The [Radius] of the scrollbar thumb's rounded rectangle corners. + /// + /// If null, the default value is platform dependent. On [TargetPlatform.android], + /// no radius is applied to the scrollbar thumb. On [TargetPlatform.iOS], + /// [CupertinoScrollbar.defaultRadius] is used. The remaining platforms have a + /// default [Radius.circular] of 8.0 pixels. + final Radius? radius; + + /// {@macro flutter.widgets.Scrollbar.interactive} + final bool? interactive; + + /// {@macro flutter.widgets.Scrollbar.notificationPredicate} + final ScrollNotificationPredicate? notificationPredicate; + + /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation} + final ScrollbarOrientation? scrollbarOrientation; + + final ValueNotifier inUseNotifier; + + @override + Widget build(BuildContext context) { + if (Theme.of(context).platform == TargetPlatform.iOS) { + return CupertinoScrollbarWithUseNotifier( + thumbVisibility: thumbVisibility ?? false, + thickness: thickness ?? CupertinoScrollbar.defaultThickness, + thicknessWhileDragging: + thickness ?? CupertinoScrollbar.defaultThicknessWhileDragging, + radius: radius ?? CupertinoScrollbar.defaultRadius, + radiusWhileDragging: + radius ?? CupertinoScrollbar.defaultRadiusWhileDragging, + controller: controller, + notificationPredicate: notificationPredicate, + scrollbarOrientation: scrollbarOrientation, + inUseNotifier: inUseNotifier, + child: child, + ); + } + return _MaterialScrollbar( + controller: controller, + thumbVisibility: thumbVisibility, + trackVisibility: trackVisibility, + thickness: thickness, + radius: radius, + notificationPredicate: notificationPredicate, + interactive: interactive, + scrollbarOrientation: scrollbarOrientation, + inUseNotifier: inUseNotifier, + child: child, + ); + } +} + +class _MaterialScrollbar extends RawScrollbar { + final ValueNotifier inUseNotifier; + const _MaterialScrollbar({ + required super.child, + required this.inUseNotifier, + super.controller, + super.thumbVisibility, + super.trackVisibility, + super.thickness, + super.radius, + ScrollNotificationPredicate? notificationPredicate, + super.interactive, + super.scrollbarOrientation, + }) : super( + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + pressDuration: Duration.zero, + notificationPredicate: + notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + @override + _MaterialScrollbarState createState() => _MaterialScrollbarState(); +} + +class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> { + late AnimationController _hoverAnimationController; + bool _dragIsActive = false; + bool _hoverIsActive = false; + late ColorScheme _colorScheme; + late ScrollbarThemeData _scrollbarTheme; + // On Android, scrollbars should match native appearance. + late bool _useAndroidScrollbar; + + @override + bool get showScrollbar => + widget.thumbVisibility ?? + _scrollbarTheme.thumbVisibility?.resolve(_states) ?? + false; + + @override + bool get enableGestures => + widget.interactive ?? + _scrollbarTheme.interactive ?? + !_useAndroidScrollbar; + + MaterialStateProperty get _trackVisibility => + MaterialStateProperty.resolveWith((Set states) { + return widget.trackVisibility ?? + _scrollbarTheme.trackVisibility?.resolve(states) ?? + false; + }); + + Set get _states => { + if (_dragIsActive) MaterialState.dragged, + if (_hoverIsActive) MaterialState.hovered, + }; + + MaterialStateProperty get _thumbColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + late Color dragColor; + late Color hoverColor; + late Color idleColor; + switch (brightness) { + case Brightness.light: + dragColor = onSurface.withOpacity(0.6); + hoverColor = onSurface.withOpacity(0.5); + idleColor = _useAndroidScrollbar + ? Theme.of(context).highlightColor.withOpacity(1.0) + : onSurface.withOpacity(0.1); + case Brightness.dark: + dragColor = onSurface.withOpacity(0.75); + hoverColor = onSurface.withOpacity(0.65); + idleColor = _useAndroidScrollbar + ? Theme.of(context).highlightColor.withOpacity(1.0) + : onSurface.withOpacity(0.3); + } + + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.dragged)) { + return _scrollbarTheme.thumbColor?.resolve(states) ?? dragColor; + } + + // If the track is visible, the thumb color hover animation is ignored and + // changes immediately. + if (_trackVisibility.resolve(states)) { + return _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor; + } + + return Color.lerp( + _scrollbarTheme.thumbColor?.resolve(states) ?? idleColor, + _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor, + _hoverAnimationController.value, + )!; + }); + } + + MaterialStateProperty get _trackColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + return MaterialStateProperty.resolveWith((Set states) { + if (showScrollbar && _trackVisibility.resolve(states)) { + return _scrollbarTheme.trackColor?.resolve(states) ?? + switch (brightness) { + Brightness.light => onSurface.withOpacity(0.03), + Brightness.dark => onSurface.withOpacity(0.05), + }; + } + return const Color(0x00000000); + }); + } + + MaterialStateProperty get _trackBorderColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + return MaterialStateProperty.resolveWith((Set states) { + if (showScrollbar && _trackVisibility.resolve(states)) { + return _scrollbarTheme.trackBorderColor?.resolve(states) ?? + switch (brightness) { + Brightness.light => onSurface.withOpacity(0.1), + Brightness.dark => onSurface.withOpacity(0.25), + }; + } + return const Color(0x00000000); + }); + } + + MaterialStateProperty get _thickness { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.hovered) && + _trackVisibility.resolve(states)) { + return widget.thickness ?? + _scrollbarTheme.thickness?.resolve(states) ?? + _kScrollbarThicknessWithTrack; + } + // The default scrollbar thickness is smaller on mobile. + return widget.thickness ?? + _scrollbarTheme.thickness?.resolve(states) ?? + (_kScrollbarThickness / (_useAndroidScrollbar ? 2 : 1)); + }); + } + + @override + void initState() { + super.initState(); + _hoverAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _hoverAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void didChangeDependencies() { + final ThemeData theme = Theme.of(context); + _colorScheme = theme.colorScheme; + _scrollbarTheme = ScrollbarTheme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + _useAndroidScrollbar = true; + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _useAndroidScrollbar = false; + } + super.didChangeDependencies(); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = _thumbColor.resolve(_states) + ..trackColor = _trackColor.resolve(_states) + ..trackBorderColor = _trackBorderColor.resolve(_states) + ..textDirection = Directionality.of(context) + ..thickness = _thickness.resolve(_states) + ..radius = widget.radius ?? + _scrollbarTheme.radius ?? + (_useAndroidScrollbar ? null : _kScrollbarRadius) + ..crossAxisMargin = _scrollbarTheme.crossAxisMargin ?? + (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin) + ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0 + ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength + ..padding = MediaQuery.paddingOf(context) + ..scrollbarOrientation = widget.scrollbarOrientation + ..ignorePointer = !enableGestures; + } + + @override + void handleThumbPressStart(Offset localPosition) { + super.handleThumbPressStart(localPosition); + setState(() { + _dragIsActive = true; + widget.inUseNotifier.value = true; + }); + } + + @override + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + super.handleThumbPressEnd(localPosition, velocity); + setState(() { + _dragIsActive = false; + widget.inUseNotifier.value = false; + }); + } + + @override + void handleHover(PointerHoverEvent event) { + super.handleHover(event); + // Check if the position of the pointer falls over the painted scrollbar + if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) { + // Pointer is hovering over the scrollbar + setState(() { + _hoverIsActive = true; + }); + _hoverAnimationController.forward(); + } else if (_hoverIsActive) { + // Pointer was, but is no longer over painted scrollbar. + setState(() { + _hoverIsActive = false; + }); + _hoverAnimationController.reverse(); + } + } + + @override + void handleHoverExit(PointerExitEvent event) { + super.handleHoverExit(event); + setState(() { + _hoverIsActive = false; + }); + _hoverAnimationController.reverse(); + } + + @override + void dispose() { + _hoverAnimationController.dispose(); + super.dispose(); + } +}