// 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 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; // The const represents the starting position of the scrollbar thumb for // the below tests. The thumb is 90 pixels long, and 8 pixels wide, with a 2 // pixel margin to the right edge of the viewport. const Rect _kMaterialDesignInitialThumbRect = Rect.fromLTRB(790.0, 0.0, 798.0, 90.0); const Radius _kDefaultThumbRadius = Radius.circular(8.0); const Color _kDefaultIdleThumbColor = Color(0x1a000000); const Color _kDefaultDragThumbColor = Color(0x99000000); void main() { test('ScrollbarThemeData copyWith, ==, hashCode basics', () { expect(const ScrollbarThemeData(), const ScrollbarThemeData().copyWith()); expect(const ScrollbarThemeData().hashCode, const ScrollbarThemeData().copyWith().hashCode); }); testWidgets('Passing no ScrollbarTheme returns defaults', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget( MaterialApp( home: ScrollConfiguration( behavior: const NoScrollbarBehavior(), child: Scrollbar( isAlwaysShown: true, showTrackOnHover: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), ), ), ); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), color: _kDefaultIdleThumbColor, ), ); // Drag scrollbar behavior const double scrollAmount = 10.0; final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), // Drag color color: _kDefaultDragThumbColor, ), ); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Hover scrollbar behavior final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); await gesture.addPointer(); await gesture.moveTo(const Offset(794.0, 5.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints ..rect( rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), color: const Color(0x08000000), ) ..line( p1: const Offset(784.0, 0.0), p2: const Offset(784.0, 600.0), strokeWidth: 1.0, color: const Color(0x1a000000), ) ..rrect( rrect: RRect.fromRectAndRadius( // Scrollbar thumb is larger const Rect.fromLTRB(786.0, 10.0, 798.0, 100.0), _kDefaultThumbRadius, ), // Hover color color: const Color(0x80000000), ), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.fuchsia, }), ); testWidgets('Scrollbar uses values from ScrollbarTheme', (WidgetTester tester) async { final ScrollbarThemeData scrollbarTheme = _scrollbarTheme(); final ScrollController scrollController = ScrollController(); await tester.pumpWidget(MaterialApp( theme: ThemeData( scrollbarTheme: scrollbarTheme, ), home: ScrollConfiguration( behavior: const NoScrollbarBehavior(), child: Scrollbar( isAlwaysShown: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), ), )); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(785.0, 10.0, 795.0, 97.0), const Radius.circular(6.0), ), color: const Color(0xff4caf50), ), ); // Drag scrollbar behavior const double scrollAmount = 10.0; final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(785.0, 10.0, 795.0, 97.0), const Radius.circular(6.0), ), // Drag color color: const Color(0xfff44336), ), ); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Hover scrollbar behavior final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); await gesture.addPointer(); await gesture.moveTo(const Offset(794.0, 15.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints ..rect( rect: const Rect.fromLTRB(770.0, 0.0, 800.0, 600.0), color: const Color(0xff000000), ) ..line( p1: const Offset(770.0, 00.0), p2: const Offset(770.0, 600.0), strokeWidth: 1.0, color: const Color(0xffffeb3b), ) ..rrect( rrect: RRect.fromRectAndRadius( // Scrollbar thumb is larger const Rect.fromLTRB(775.0, 20.0, 795.0, 107.0), const Radius.circular(6.0), ), // Hover color color: const Color(0xff2196f3), ), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.fuchsia, }), ); testWidgets( 'Scrollbar uses values from ScrollbarTheme if exists instead of values from Theme', (WidgetTester tester) async { final ScrollbarThemeData scrollbarTheme = _scrollbarTheme(); final ScrollController scrollController = ScrollController(); await tester.pumpWidget( MaterialApp( theme: ThemeData( scrollbarTheme: scrollbarTheme, ), home: ScrollConfiguration( behavior: const NoScrollbarBehavior(), child: ScrollbarTheme( data: _scrollbarTheme().copyWith( thumbColor: MaterialStateProperty.all(const Color(0xFF000000)), ), child: Scrollbar( thumbVisibility: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), ), ), ), ); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints ..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(785.0, 10.0, 795.0, 97.0), const Radius.circular(6.0), ), color: const Color(0xFF000000), ), ); }, ); testWidgets('ScrollbarTheme can disable gestures', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget(MaterialApp( theme: ThemeData(scrollbarTheme: const ScrollbarThemeData(interactive: false)), home: Scrollbar( isAlwaysShown: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), )); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), color: _kDefaultIdleThumbColor, ), ); // Try to drag scrollbar. const double scrollAmount = 10.0; final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Expect no change expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), color: _kDefaultIdleThumbColor, ), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.fuchsia })); testWidgets('Scrollbar.interactive takes priority over ScrollbarTheme', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget(MaterialApp( theme: ThemeData(scrollbarTheme: const ScrollbarThemeData(interactive: false)), home: Scrollbar( interactive: true, isAlwaysShown: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), )); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), color: _kDefaultIdleThumbColor, ), ); // Drag scrollbar. const double scrollAmount = 10.0; final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Gestures handled by Scrollbar. expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), _kDefaultThumbRadius, ), color: _kDefaultIdleThumbColor, ), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.fuchsia })); testWidgets('Scrollbar widget properties take priority over theme', (WidgetTester tester) async { const double thickness = 4.0; const double hoverThickness = 4.0; const bool showTrackOnHover = true; const Radius radius = Radius.circular(3.0); final ScrollController scrollController = ScrollController(); await tester.pumpWidget( MaterialApp( theme: ThemeData( colorScheme: const ColorScheme.light(), ), home: ScrollConfiguration( behavior: const NoScrollbarBehavior(), child: Scrollbar( thickness: thickness, hoverThickness: hoverThickness, thumbVisibility: true, showTrackOnHover: showTrackOnHover, radius: radius, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), ), ), ); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(794.0, 0.0, 798.0, 90.0), const Radius.circular(3.0), ), color: _kDefaultIdleThumbColor, ), ); // Drag scrollbar behavior const double scrollAmount = 10.0; final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(794.0, 0.0, 798.0, 90.0), const Radius.circular(3.0), ), // Drag color color: _kDefaultDragThumbColor, ), ); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Hover scrollbar behavior final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); await gesture.addPointer(); await gesture.moveTo(const Offset(794.0, 5.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints ..rect( rect: const Rect.fromLTRB(792.0, 0.0, 800.0, 600.0), color: const Color(0x08000000), ) ..line( p1: const Offset(792.0, 0.0), p2: const Offset(792.0, 600.0), strokeWidth: 1.0, color: const Color(0x1a000000), ) ..rrect( rrect: RRect.fromRectAndRadius( // Scrollbar thumb is larger const Rect.fromLTRB(794.0, 10.0, 798.0, 100.0), const Radius.circular(3.0), ), // Hover color color: const Color(0x80000000), ), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.fuchsia, }), ); testWidgets('ThemeData colorScheme is used when no ScrollbarTheme is set', (WidgetTester tester) async { Widget buildFrame(ThemeData appTheme) { final ScrollController scrollController = ScrollController(); return MaterialApp( theme: appTheme, home: ScrollConfiguration( behavior: const NoScrollbarBehavior(), child: Scrollbar( isAlwaysShown: true, showTrackOnHover: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), ), ); } // Scrollbar defaults for light themes: // - coloring based on ColorScheme.onSurface await tester.pumpWidget(buildFrame(ThemeData( colorScheme: const ColorScheme.light(), ))); await tester.pumpAndSettle(); // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), color: _kDefaultIdleThumbColor, ), ); // Drag scrollbar behavior const double scrollAmount = 10.0; TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( _kMaterialDesignInitialThumbRect, _kDefaultThumbRadius, ), // Drag color color: _kDefaultDragThumbColor, ), ); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Hover scrollbar behavior final TestGesture hoverGesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); await hoverGesture.addPointer(); addTearDown(hoverGesture.removePointer); await hoverGesture.moveTo(const Offset(794.0, 5.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints ..rect( rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), color: const Color(0x08000000), ) ..line( p1: const Offset(784.0, 0.0), p2: const Offset(784.0, 600.0), strokeWidth: 1.0, color: const Color(0x1a000000), ) ..rrect( rrect: RRect.fromRectAndRadius( // Scrollbar thumb is larger const Rect.fromLTRB(786.0, 10.0, 798.0, 100.0), _kDefaultThumbRadius, ), // Hover color color: const Color(0x80000000), ), ); await hoverGesture.moveTo(Offset.zero); // Scrollbar defaults for dark themes: // - coloring slightly different based on ColorScheme.onSurface await tester.pumpWidget(buildFrame(ThemeData( colorScheme: const ColorScheme.dark(), ))); await tester.pumpAndSettle(); // Theme change animation // Idle scrollbar behavior expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), _kDefaultThumbRadius, ), color: const Color(0x4dffffff), ), ); // Drag scrollbar behavior dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0), _kDefaultThumbRadius, ), // Drag color color: const Color(0xbfffffff), ), ); await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount)); await tester.pumpAndSettle(); await dragScrollbarGesture.up(); await tester.pumpAndSettle(); // Hover scrollbar behavior await hoverGesture.moveTo(const Offset(794.0, 5.0)); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints ..rect( rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0), color: const Color(0x0dffffff), ) ..line( p1: const Offset(784.0, 0.0), p2: const Offset(784.0, 600.0), strokeWidth: 1.0, color: const Color(0x40ffffff), ) ..rrect( rrect: RRect.fromRectAndRadius( // Scrollbar thumb is larger const Rect.fromLTRB(786.0, 20.0, 798.0, 110.0), _kDefaultThumbRadius, ), // Hover color color: const Color(0xa6ffffff), ), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.fuchsia, }), ); testWidgets('ScrollbarThemeData.trackVisibility test', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); bool? getTrackVisibility(Set<MaterialState> states) { return true; } await tester.pumpWidget( MaterialApp( theme: ThemeData().copyWith( scrollbarTheme: _scrollbarTheme( trackVisibility: MaterialStateProperty.resolveWith(getTrackVisibility), ), ), home: ScrollConfiguration( behavior: const NoScrollbarBehavior(), child: Scrollbar( isAlwaysShown: true, showTrackOnHover: true, controller: scrollController, child: SingleChildScrollView( controller: scrollController, child: const SizedBox(width: 4000.0, height: 4000.0), ), ), ), ), ); await tester.pumpAndSettle(); expect( find.byType(Scrollbar), paints ..rect(color: const Color(0x08000000)) ..line( strokeWidth: 1.0, color: const Color(0x1a000000), ) ..rrect(color: const Color(0xff4caf50)), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.fuchsia, }), ); testWidgets('Default ScrollbarTheme debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); const ScrollbarThemeData().debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) .map((DiagnosticsNode node) => node.toString()) .toList(); expect(description, <String>[]); }); testWidgets('ScrollbarTheme implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); ScrollbarThemeData( thickness: MaterialStateProperty.resolveWith(_getThickness), showTrackOnHover: true, thumbVisibility: MaterialStateProperty.resolveWith(_getThumbVisibility), radius: const Radius.circular(3.0), thumbColor: MaterialStateProperty.resolveWith(_getThumbColor), trackColor: MaterialStateProperty.resolveWith(_getTrackColor), trackBorderColor: MaterialStateProperty.resolveWith(_getTrackBorderColor), crossAxisMargin: 3.0, mainAxisMargin: 6.0, minThumbLength: 120.0, ).debugFillProperties(builder); final List<String> description = builder.properties .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) .map((DiagnosticsNode node) => node.toString()) .toList(); expect(description, <String>[ "thumbVisibility: Instance of '_MaterialStatePropertyWith<bool?>'", "thickness: Instance of '_MaterialStatePropertyWith<double?>'", 'showTrackOnHover: true', 'radius: Radius.circular(3.0)', "thumbColor: Instance of '_MaterialStatePropertyWith<Color?>'", "trackColor: Instance of '_MaterialStatePropertyWith<Color?>'", "trackBorderColor: Instance of '_MaterialStatePropertyWith<Color?>'", 'crossAxisMargin: 3.0', 'mainAxisMargin: 6.0', 'minThumbLength: 120.0', ]); // On the web, Dart doubles and ints are backed by the same kind of object because // JavaScript does not support integers. So, the Dart double "4.0" is identical // to "4", which results in the web evaluating to the value "4" regardless of which // one is used. This results in a difference for doubles in debugFillProperties between // the web and the rest of Flutter's target platforms. }, skip: kIsWeb); // [intended] } class NoScrollbarBehavior extends ScrollBehavior { const NoScrollbarBehavior(); @override Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) => child; } ScrollbarThemeData _scrollbarTheme({ MaterialStateProperty<double?>? thickness, MaterialStateProperty<bool?>? trackVisibility, bool showTrackOnHover = true, MaterialStateProperty<bool?>? thumbVisibility, Radius radius = const Radius.circular(6.0), MaterialStateProperty<Color?>? thumbColor, MaterialStateProperty<Color?>? trackColor, MaterialStateProperty<Color?>? trackBorderColor, double crossAxisMargin = 5.0, double mainAxisMargin = 10.0, double minThumbLength = 50.0, }) { return ScrollbarThemeData( thickness: thickness ?? MaterialStateProperty.resolveWith(_getThickness), trackVisibility: trackVisibility, showTrackOnHover: showTrackOnHover, thumbVisibility: thumbVisibility, radius: radius, thumbColor: thumbColor ?? MaterialStateProperty.resolveWith(_getThumbColor), trackColor: trackColor ?? MaterialStateProperty.resolveWith(_getTrackColor), trackBorderColor: trackBorderColor ?? MaterialStateProperty.resolveWith(_getTrackBorderColor), crossAxisMargin: crossAxisMargin, mainAxisMargin: mainAxisMargin, minThumbLength: minThumbLength, ); } double? _getThickness(Set<MaterialState> states) { if (states.contains(MaterialState.hovered)) { return 20.0; } return 10.0; } bool? _getThumbVisibility(Set<MaterialState> states) => true; Color? _getThumbColor(Set<MaterialState> states) { if (states.contains(MaterialState.dragged)) { return Colors.red; } if (states.contains(MaterialState.hovered)) { return Colors.blue; } return Colors.green; } Color? _getTrackColor(Set<MaterialState> states) { if (states.contains(MaterialState.hovered)) { return Colors.black; } return null; } Color? _getTrackBorderColor(Set<MaterialState> states) { if (states.contains(MaterialState.hovered)) { return Colors.yellow; } return null; }