diff --git a/example/pubspec.lock b/example/pubspec.lock index ff940122..0be15b50 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -570,10 +570,10 @@ packages: dependency: transitive description: name: scrollable_positioned_list - sha256: f998e48b93314f29e27d31448c8f095d342e6680a020ed0b97524ccb85edf672 + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" url: "https://pub.dev" source: hosted - version: "0.3.2" + version: "0.3.8" simple_html_css: dependency: "direct main" description: diff --git a/lib/questionnaires/view/src/questionnaire_scroller.dart b/lib/questionnaires/view/src/questionnaire_scroller.dart index 550cb116..aab07cf5 100644 --- a/lib/questionnaires/view/src/questionnaire_scroller.dart +++ b/lib/questionnaires/view/src/questionnaire_scroller.dart @@ -48,9 +48,7 @@ class QuestionnaireScroller extends StatefulWidget { }); @override - State createState() { - return _QuestionnaireScrollerState(); - } + State createState() => _QuestionnaireScrollerState(); } class _QuestionnaireScrollerState extends State { @@ -157,9 +155,6 @@ class _QuestionnaireScrollerState extends State { questionnaireModelDefaults: widget.questionnaireModelDefaults, builder: (BuildContext context) { _belowFillerContext = context; - final questionnaireFiller = QuestionnaireResponseFiller.of(context); - - final totalLength = questionnaireFiller.fillerItemModels.length; _logger.trace( 'Scroll position: ${_itemPositionsListener.itemPositions.value}', @@ -167,20 +162,16 @@ class _QuestionnaireScrollerState extends State { return widget.scaffoldBuilder.build( context, - setStateCallback: (fn) { - setState(fn); - }, + setStateCallback: (fn) => setState(fn), child: LayoutBuilder( builder: (context, constraints) { const edgeInsets = 8.0; const twice = 2; - return ScrollablePositionedList.builder( + return QuestionnaireScrollerView( itemScrollController: _listScrollController, itemPositionsListener: _itemPositionsListener, - itemCount: totalLength, - padding: const EdgeInsets.all(edgeInsets), - minCacheExtent: 200, // Allow tabbing to prev/next items + // Allow tabbing to prev/next items itemBuilder: (BuildContext context, int i) { return Row( children: [ diff --git a/lib/questionnaires/view/src/questionnaire_scroller_view.dart b/lib/questionnaires/view/src/questionnaire_scroller_view.dart new file mode 100644 index 00000000..11b42230 --- /dev/null +++ b/lib/questionnaires/view/src/questionnaire_scroller_view.dart @@ -0,0 +1,101 @@ +import 'package:faiadashu/faiadashu.dart'; +import 'package:flutter/material.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class QuestionnaireScrollerView extends StatefulWidget { + final ItemScrollController itemScrollController; + final ItemPositionsListener itemPositionsListener; + final Widget Function(BuildContext, int) itemBuilder; + + const QuestionnaireScrollerView({ + super.key, + required this.itemScrollController, + required this.itemPositionsListener, + required this.itemBuilder, + }); + + @override + State createState() => + _QuestionnaireScrollerViewState(); +} + +class _QuestionnaireScrollerViewState extends State { + final _scrollOffsetController = ScrollOffsetController(); + + double _buttonOpacity = 0.0; + bool _showScrollDownButton = false; + + @override + void initState() { + super.initState(); + widget.itemPositionsListener.itemPositions + .addListener(_updateButtonVisibility); + } + + @override + void dispose() { + super.dispose(); + widget.itemPositionsListener.itemPositions + .removeListener(_updateButtonVisibility); + } + + void _updateButtonVisibility() { + final items = QuestionnaireResponseFiller.of(context).fillerItemModels; + final positions = widget.itemPositionsListener.itemPositions.value; + // Check if the bottom of the list is visible + final isEndVisible = positions.any( + (ItemPosition position) => + position.index == items.length - 1 && position.itemTrailingEdge <= 1, + ); + + final newValue = isEndVisible ? 0.0 : 1.0; + if (_buttonOpacity != newValue) { + setState(() { + _buttonOpacity = newValue; + _showScrollDownButton = true; + }); + } + } + + void _scrollToBottomItem() { + _scrollOffsetController.animateScroll( + offset: 250, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOutCubic, + ); + } + + void _onAnimationEnded() { + setState(() { + _showScrollDownButton = false; + }); + } + + @override + Widget build(BuildContext context) { + final itemCount = + QuestionnaireResponseFiller.of(context).fillerItemModels.length; + + return Stack( + children: [ + ScrollablePositionedList.builder( + itemScrollController: widget.itemScrollController, + itemPositionsListener: widget.itemPositionsListener, + scrollOffsetController: _scrollOffsetController, + itemCount: itemCount, + itemBuilder: (context, index) => widget.itemBuilder(context, index), + padding: const EdgeInsets.all(8.0), + minCacheExtent: 200, + ), + if (_showScrollDownButton && + QuestionnaireTheme.of(context).showScrollDownButton) + QuestionnaireTheme.of(context).scrollDownButton( + context, + _buttonOpacity, + _onAnimationEnded, + _scrollToBottomItem, + ), + ], + ); + } +} diff --git a/lib/questionnaires/view/src/questionnaire_stepper_page_view.dart b/lib/questionnaires/view/src/questionnaire_stepper_page_view.dart index 006895be..8098b0f6 100644 --- a/lib/questionnaires/view/src/questionnaire_stepper_page_view.dart +++ b/lib/questionnaires/view/src/questionnaire_stepper_page_view.dart @@ -20,8 +20,10 @@ class QuestionnaireStepperPageView extends StatefulWidget { static QuestionnaireStepperPageViewData of(BuildContext context) { final result = context.dependOnInheritedWidgetOfExactType< _QuestionnaireStepperPageViewInheritedWidget>(); - assert(result != null, - 'No QuestionnaireStepperInheritedWidget found in context',); + assert( + result != null, + 'No QuestionnaireStepperInheritedWidget found in context', + ); return result!.data; } @@ -33,6 +35,7 @@ class QuestionnaireStepperPageView extends StatefulWidget { class _QuestionnaireStepperPageViewState extends State { final PageController _pageController = PageController(); + bool _isPageSwiping = false; bool _hasRequestsRunning = false; QuestionnaireItemFiller? _currentQuestionnaireItemFiller; @@ -40,6 +43,7 @@ class _QuestionnaireStepperPageViewState void initState() { super.initState(); widget.data.controller._attach(this); + _pageController.addListener(_scrollListener); } /// Determines if we can proceed to the next page. @@ -93,6 +97,12 @@ class _QuestionnaireStepperPageViewState widget.data.onPageChanged?.call(index); } + void _scrollListener() { + setState(() { + _isPageSwiping = _pageController.page?.round() != _pageController.page; + }); + } + @override Widget build(BuildContext context) { return _QuestionnaireStepperPageViewInheritedWidget( @@ -114,9 +124,12 @@ class _QuestionnaireStepperPageViewState _updateVisibleItem(index); if (data == null) return null; - return QuestionnaireTheme.of(context).stepperPageItemBuilder( - context, - data, + return _ScrollableArea( + showScrollDownButton: !_isPageSwiping, + child: QuestionnaireTheme.of(context).stepperPageItemBuilder( + context, + data, + ), ); }, physics: widget.data.physics, @@ -127,6 +140,8 @@ class _QuestionnaireStepperPageViewState @override void dispose() { widget.data.controller._detach(); + _pageController.removeListener(_scrollListener); + _pageController.dispose(); super.dispose(); } } @@ -257,3 +272,91 @@ class QuestionnaireStepperPageViewData { this.controller = controller ?? QuestionnaireStepperPageViewController(); } } + +class _ScrollableArea extends StatefulWidget { + final Widget child; + final bool showScrollDownButton; + + const _ScrollableArea({ + required this.child, + this.showScrollDownButton = true, + }); + + @override + State createState() => _ScrollableAreaState(); +} + +class _ScrollableAreaState extends State<_ScrollableArea> { + late ScrollController _scrollController; + bool _showScrollDownButton = true; + bool _hasMoreContent = false; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + WidgetsBinding.instance.addPostFrameCallback((_) => _checkScroll()); + _scrollController.addListener(_scrollListener); + } + + void _checkScroll() { + final hasMoreContent = _scrollController.position.maxScrollExtent > 0; + if (hasMoreContent != _hasMoreContent) { + setState(() { + _hasMoreContent = hasMoreContent; + }); + } + } + + void _scrollListener() { + setState(() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent) { + _hasMoreContent = false; + _showScrollDownButton = true; + } else { + _hasMoreContent = true; + } + }); + } + + void _onAnimationEnded() { + setState(() { + _showScrollDownButton = widget.showScrollDownButton && _hasMoreContent; + }); + } + + void _scrollToBottomItem() { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + SingleChildScrollView( + controller: _scrollController, + child: widget.child, + ), + if (_showScrollDownButton && + QuestionnaireTheme.of(context).showScrollDownButton) + QuestionnaireTheme.of(context).scrollDownButton( + context, + widget.showScrollDownButton && _hasMoreContent ? 1 : 0, + _onAnimationEnded, + _scrollToBottomItem, + ), + ], + ); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } +} diff --git a/lib/questionnaires/view/src/questionnaire_theme.dart b/lib/questionnaires/view/src/questionnaire_theme.dart index abad555c..b72a01eb 100644 --- a/lib/questionnaires/view/src/questionnaire_theme.dart +++ b/lib/questionnaires/view/src/questionnaire_theme.dart @@ -36,6 +36,9 @@ class QuestionnaireTheme extends InheritedWidget { class QuestionnaireThemeData { static final _logger = Logger(QuestionnaireThemeData); + /// Returns whether a scroll down button is shown + final bool showScrollDownButton; + /// Returns whether user will be offered option to skip question. final bool canSkipQuestions; @@ -155,6 +158,13 @@ class QuestionnaireThemeData { int pageIndex, ) stepperQuestionnaireItemFiller; + final Widget Function( + BuildContext context, + double buttonOpacity, + VoidCallback onEndAnimation, + VoidCallback scrollToBottom, + ) scrollDownButton; + /// Builds layouts for QuestionnaireStepper pages. /// If there are no more pages to show, this method must return `null`. /// @@ -166,6 +176,7 @@ class QuestionnaireThemeData { const QuestionnaireThemeData({ this.canSkipQuestions = false, + this.showScrollDownButton = true, this.showProgress = true, this.showScore = true, this.autoCompleteThreshold = defaultAutoCompleteThreshold, @@ -185,6 +196,7 @@ class QuestionnaireThemeData { this.stepperQuestionnaireItemFiller = _defaultStepperQuestionnaireItemFiller, this.stepperPageItemBuilder = _defaultStepperPageItemBuilder, + this.scrollDownButton = _defaultScrollDownButton, }); /// Returns a [QuestionnaireItemFiller] for a given [QuestionnaireResponseFiller]. @@ -450,4 +462,29 @@ class QuestionnaireThemeData { child: itemFiller, ); } + + static Widget _defaultScrollDownButton( + BuildContext context, + double buttonOpacity, + VoidCallback onEndAnimation, + VoidCallback scrollToBottom, + ) { + return Positioned( + bottom: 10.0, + left: 0.0, + right: 0.0, + child: Center( + child: AnimatedOpacity( + opacity: buttonOpacity, + duration: const Duration(milliseconds: 250), + onEnd: onEndAnimation, + child: FloatingActionButton( + mini: true, + onPressed: scrollToBottom, + child: const Icon(Icons.arrow_downward), + ), + ), + ), + ); + } } diff --git a/lib/questionnaires/view/view.dart b/lib/questionnaires/view/view.dart index 07cb8a0b..c5e29782 100644 --- a/lib/questionnaires/view/view.dart +++ b/lib/questionnaires/view/view.dart @@ -13,6 +13,7 @@ export 'src/questionnaire_loading_indicator.dart'; export 'src/questionnaire_page_scaffold.dart'; export 'src/questionnaire_scroller.dart'; export 'src/questionnaire_scroller_page.dart'; +export 'src/questionnaire_scroller_view.dart'; export 'src/questionnaire_stepper.dart'; export 'src/questionnaire_stepper_page.dart'; export 'src/questionnaire_stepper_page_view.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index f1a30852..15ae94dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: markdown: ^7.0.0 meta: ^1.7.0 mime: ^1.0.4 - scrollable_positioned_list: ^0.3.2 + scrollable_positioned_list: ^0.3.8 simple_html_css: ^4.0.0 webview_flutter: ^4.0.0 webview_flutter_web: ^0.2.0