diff --git a/lib/src/components/month_view_components.dart b/lib/src/components/month_view_components.dart index af17d548..2f99cbb2 100644 --- a/lib/src/components/month_view_components.dart +++ b/lib/src/components/month_view_components.dart @@ -3,6 +3,7 @@ // that can be found in the LICENSE file. import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../calendar_event_data.dart'; @@ -121,6 +122,7 @@ class FilledCell extends StatelessWidget { return Container( color: backgroundColor, child: Column( + mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 5.0, @@ -275,3 +277,443 @@ class WeekDayTile extends StatelessWidget { ); } } + +typedef WidgetBuilder = Widget Function(BuildContext context, int index); + +class ExpandablePageView extends StatefulWidget { + /// List of widgets to display + /// + /// Corresponds to Material's PageView's children parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final List? children; + + /// Number of widgets to display + /// + /// Corresponds to Material PageView's itemCount parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final int? itemCount; + + /// Item builder function + /// + /// Corresponds to Material's PageView's itemBuilder parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final WidgetBuilder? itemBuilder; + + /// An object that can be used to control the position to which this page view is scrolled. + /// + /// Corresponds to Material's PageView's controller parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final PageController? controller; + + /// Called whenever the page in the center of the viewport changes. + /// + /// Corresponds to Material's PageView's onPageChanged parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final ValueChanged? onPageChanged; + + /// Whether the page view scrolls in the reading direction. + /// + /// Corresponds to Material's PageView's reverse parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final bool reverse; + + /// Duration of PageView resize animation upon page change + /// + /// Defaults to [100 milliseconds] + final Duration animationDuration; + + /// Curve use for PageView resize animation upon page change + /// + /// Defaults to [Curves.easeInOutCubic] + final Curve animationCurve; + + /// How the page view should respond to user input. + /// + /// Corresponds to Material's PageView's physics parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final ScrollPhysics? physics; + + /// Set to false to disable page snapping, useful for custom scroll behavior. + /// + /// Corresponds to Material's PageView's pageSnapping parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final bool pageSnapping; + + /// Determines the way that drag start behavior is handled. + /// + /// Corresponds to Material's PageView's dragStartBehavior parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final DragStartBehavior dragStartBehavior; + + /// Controls whether the widget's pages will respond to [RenderObject.showOnScreen], which will allow for implicit accessibility scrolling. + /// + /// Corresponds to Material's PageView's allowImplicitScrolling parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final bool allowImplicitScrolling; + + /// Restoration ID to save and restore the scroll offset of the scrollable. + /// + /// Corresponds to Material's PageView's restorationId parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final String? restorationId; + + /// The content will be clipped (or not) according to this option. + /// + /// Corresponds to Material's PageView's clipBehavior parameter: https://api.flutter.dev/flutter/widgets/PageView-class.html + final Clip clipBehavior; + + /// Whether to animate the first page displayed by this widget. + /// + /// By default (false) [ExpandablePageView] will resize to the size of it's + /// initially displayed page without any animation. + final bool animateFirstPage; + + /// Determines the alignment of the content when animating. Useful when building centered or bottom-aligned PageViews. + final Alignment alignment; + + /// The estimated size of displayed pages. + /// + /// This property can be used to indicate how big a page will be more or less. + /// By default (0.0) all pages will have their initial sizes set to 0.0 + /// until they report that their size changed, which will result in + /// [ExpandablePageView] size animation. This can lead to a behaviour + /// when after changing the page, [ExpandablePageView] will first shrink to 0.0 + /// and then animate to the size of the page. + /// + /// For example: If there is certainty that most pages displayed by [ExpandablePageView] + /// will vary from 200 to 600 in size, then [estimatedPageSize] could be set to some + /// value in that range, to at least partially remove the "shrink and expand" effect. + /// + /// Setting it to a value much bigger than most pages' sizes might result in a + /// reversed - "expand and shrink" - effect. + final double estimatedPageSize; + + ///A ScrollBehavior that will be applied to this widget individually. + // + // Defaults to null, wherein the inherited ScrollBehavior is copied and modified to alter the viewport decoration, like Scrollbars. + // + // ScrollBehaviors also provide ScrollPhysics. If an explicit ScrollPhysics is provided in physics, it will take precedence, followed by scrollBehavior, and then the inherited ancestor ScrollBehavior. + // + // The ScrollBehavior of the inherited ScrollConfiguration will be modified by default to not apply a Scrollbar. + final ScrollBehavior? scrollBehavior; + + ///The axis along which the page view scrolls. + // + // Defaults to Axis.horizontal. + final Axis scrollDirection; + + /// Whether to add padding to both ends of the list. + /// + /// If this is set to true and [PageController.viewportFraction] < 1.0, padding will be added + /// such that the first and last child slivers will be in the center of + /// the viewport when scrolled all the way to the start or end, respectively. + /// + /// If [PageController.viewportFraction] >= 1.0, this property has no effect. + /// + /// This property defaults to true and must not be null. + final bool padEnds; + + ExpandablePageView({ + required List children, + this.controller, + this.onPageChanged, + this.reverse = false, + this.animationDuration = const Duration(milliseconds: 100), + this.animationCurve = Curves.easeInOutCubic, + this.physics, + this.pageSnapping = true, + this.dragStartBehavior = DragStartBehavior.start, + this.allowImplicitScrolling = false, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + this.animateFirstPage = false, + this.estimatedPageSize = 0.0, + this.alignment = Alignment.topCenter, + this.scrollBehavior, + this.scrollDirection = Axis.horizontal, + this.padEnds = true, + Key? key, + }) : assert(estimatedPageSize >= 0.0), + children = children, + itemBuilder = null, + itemCount = null, + super(key: key); + + ExpandablePageView.builder({ + required int itemCount, + required WidgetBuilder itemBuilder, + this.controller, + this.onPageChanged, + this.reverse = false, + this.animationDuration = const Duration(milliseconds: 100), + this.animationCurve = Curves.easeInOutCubic, + this.physics, + this.pageSnapping = true, + this.dragStartBehavior = DragStartBehavior.start, + this.allowImplicitScrolling = false, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + this.animateFirstPage = false, + this.estimatedPageSize = 0.0, + this.alignment = Alignment.topCenter, + this.scrollBehavior, + this.scrollDirection = Axis.horizontal, + this.padEnds = true, + Key? key, + }) : assert(estimatedPageSize >= 0.0), + children = null, + itemCount = itemCount, + itemBuilder = itemBuilder, + super(key: key); + + @override + _ExpandablePageViewState createState() => _ExpandablePageViewState(); +} + +class _ExpandablePageViewState extends State { + late PageController _pageController; + late List _sizes; + int _currentPage = 0; + int _previousPage = 0; + bool _shouldDisposePageController = false; + bool _firstPageLoaded = false; + + double get _currentSize => _sizes[_currentPage]; + + double get _previousSize => _sizes[_previousPage]; + + bool get isBuilder => widget.itemBuilder != null; + + bool get _isHorizontalScroll => widget.scrollDirection == Axis.horizontal; + + @override + void initState() { + super.initState(); + _sizes = _prepareSizes(); + _pageController = widget.controller ?? PageController(); + _pageController.addListener(_updatePage); + _currentPage = _pageController.initialPage.clamp(0, _sizes.length - 1); + _previousPage = _currentPage - 1 < 0 ? 0 : _currentPage - 1; + _shouldDisposePageController = widget.controller == null; + } + + @override + void didUpdateWidget(covariant ExpandablePageView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?.removeListener(_updatePage); + _pageController = widget.controller ?? PageController(); + _pageController.addListener(_updatePage); + _shouldDisposePageController = widget.controller == null; + } + if (_shouldReinitializeHeights(oldWidget)) { + _reinitializeSizes(); + } + } + + @override + void dispose() { + _pageController.removeListener(_updatePage); + if (_shouldDisposePageController) { + _pageController.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + curve: widget.animationCurve, + duration: _getDuration(), + tween: Tween(begin: _previousSize, end: _currentSize), + builder: (context, value, child) => SizedBox( + height: _isHorizontalScroll ? value : null, + width: !_isHorizontalScroll ? value : null, + child: child, + ), + child: _buildPageView(), + ); + } + + bool _shouldReinitializeHeights(ExpandablePageView oldWidget) { + if (oldWidget.itemBuilder != null && isBuilder) { + return oldWidget.itemCount != widget.itemCount; + } + return oldWidget.children?.length != widget.children?.length; + } + + void _reinitializeSizes() { + final currentPageSize = _sizes[_currentPage]; + _sizes = _prepareSizes(); + + if (_currentPage >= _sizes.length) { + final differenceFromPreviousToCurrent = _previousPage - _currentPage; + _currentPage = _sizes.length - 1; + widget.onPageChanged?.call(_currentPage); + + _previousPage = (_currentPage + differenceFromPreviousToCurrent) + .clamp(0, _sizes.length - 1); + } + + _previousPage = _previousPage.clamp(0, _sizes.length - 1); + _sizes[_currentPage] = currentPageSize; + } + + Duration _getDuration() { + if (_firstPageLoaded) { + return widget.animationDuration; + } + return widget.animateFirstPage ? widget.animationDuration : Duration.zero; + } + + Widget _buildPageView() { + if (isBuilder) { + return PageView.builder( + controller: _pageController, + itemBuilder: _itemBuilder, + itemCount: widget.itemCount, + onPageChanged: widget.onPageChanged, + reverse: widget.reverse, + physics: widget.physics, + pageSnapping: widget.pageSnapping, + dragStartBehavior: widget.dragStartBehavior, + allowImplicitScrolling: widget.allowImplicitScrolling, + restorationId: widget.restorationId, + clipBehavior: widget.clipBehavior, + scrollBehavior: widget.scrollBehavior, + scrollDirection: widget.scrollDirection, + padEnds: widget.padEnds, + ); + } + return PageView( + controller: _pageController, + children: _sizeReportingChildren(), + onPageChanged: widget.onPageChanged, + reverse: widget.reverse, + physics: widget.physics, + pageSnapping: widget.pageSnapping, + dragStartBehavior: widget.dragStartBehavior, + allowImplicitScrolling: widget.allowImplicitScrolling, + restorationId: widget.restorationId, + clipBehavior: widget.clipBehavior, + scrollBehavior: widget.scrollBehavior, + scrollDirection: widget.scrollDirection, + padEnds: widget.padEnds, + ); + } + + List _prepareSizes() { + if (isBuilder) { + return List.filled(widget.itemCount!, widget.estimatedPageSize); + } else { + return widget.children!.map((child) => widget.estimatedPageSize).toList(); + } + } + + void _updatePage() { + final newPage = _pageController.page!.round(); + if (_currentPage != newPage) { + setState(() { + _firstPageLoaded = true; + _previousPage = _currentPage; + _currentPage = newPage; + }); + } + } + + Widget _itemBuilder(BuildContext context, int index) { + final item = widget.itemBuilder!(context, index); + return OverflowPage( + onSizeChange: (size) => setState( + () => _sizes[index] = _isHorizontalScroll ? size.height : size.width, + ), + child: item, + alignment: widget.alignment, + scrollDirection: widget.scrollDirection, + ); + } + + List _sizeReportingChildren() => widget.children! + .asMap() + .map( + (index, child) => MapEntry( + index, + OverflowPage( + onSizeChange: (size) => setState( + () => _sizes[index] = + _isHorizontalScroll ? size.height : size.width, + ), + child: child, + alignment: widget.alignment, + scrollDirection: widget.scrollDirection, + ), + ), + ) + .values + .toList(); +} + +class OverflowPage extends StatelessWidget { + final ValueChanged onSizeChange; + final Widget child; + final Alignment alignment; + final Axis scrollDirection; + + const OverflowPage({ + required this.onSizeChange, + required this.child, + required this.alignment, + required this.scrollDirection, + }); + + @override + Widget build(BuildContext context) { + return OverflowBox( + minHeight: scrollDirection == Axis.horizontal ? 0 : null, + minWidth: scrollDirection == Axis.vertical ? 0 : null, + maxHeight: scrollDirection == Axis.horizontal ? double.infinity : null, + maxWidth: scrollDirection == Axis.vertical ? double.infinity : null, + alignment: alignment, + child: SizeReportingWidget( + onSizeChange: onSizeChange, + child: child, + ), + ); + } +} + +class SizeReportingWidget extends StatefulWidget { + final Widget child; + final ValueChanged onSizeChange; + + const SizeReportingWidget({ + Key? key, + required this.child, + required this.onSizeChange, + }) : super(key: key); + + @override + _SizeReportingWidgetState createState() => _SizeReportingWidgetState(); +} + +class _SizeReportingWidgetState extends State { + final _widgetKey = GlobalKey(); + Size? _oldSize; + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize()); + return NotificationListener( + onNotification: (_) { + WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize()); + return true; + }, + child: SizeChangedLayoutNotifier( + child: Container( + key: _widgetKey, + child: widget.child, + ), + ), + ); + } + + void _notifySize() { + final context = _widgetKey.currentContext; + if (context == null) return; + final size = context.size; + if (_oldSize != size) { + _oldSize = size; + widget.onSizeChange(size!); + } + } +} + diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 5b87890e..36a10103 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -22,8 +22,13 @@ class Constants { static const Color white = Color(0xffffffff); static const Color offWhite = Color(0xfff0f0f0); static const Color headerBackground = Color(0xFFDCF0FF); + static Color get randomColor { return Color.fromRGBO(_random.nextInt(_maxColor), _random.nextInt(_maxColor), _random.nextInt(_maxColor), 1); } + + static const int monthHasFourWeek = 28; + static const int monthHasFiveWeek = 35; + static const int monthHasSixWeek = 42; } diff --git a/lib/src/month_view/month_view.dart b/lib/src/month_view/month_view.dart index 11a794bd..d0256c29 100644 --- a/lib/src/month_view/month_view.dart +++ b/lib/src/month_view/month_view.dart @@ -138,9 +138,13 @@ class MonthView extends StatefulWidget { /// Option for SafeArea. final SafeAreaOption safeAreaOption; + /// Hiding the additional week that doesn't belong in the current month + final bool hideExtraWeek; + /// Main [Widget] to display month view. const MonthView({ Key? key, + this.hideExtraWeek = false, this.showBorder = true, this.borderColor = Constants.defaultBorderColor, this.cellBuilder, @@ -166,7 +170,9 @@ class MonthView extends StatefulWidget { this.weekDayStringBuilder, this.headerStyle = const HeaderStyle(), this.safeAreaOption = const SafeAreaOption(), - }) : super(key: key); + }) : assert(!(hideExtraWeek == true && useAvailableVerticalSpace == true), + "Can't user both property at same time"), + super(key: key); @override MonthViewState createState() => MonthViewState(); @@ -284,81 +290,89 @@ class MonthViewState extends State> { child: SizedBox( width: _width, child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: _width, child: _headerBuilder(_currentDate), ), - Expanded( - child: PageView.builder( - controller: _pageController, - onPageChanged: _onPageChange, - itemBuilder: (_, index) { - final date = DateTime(_minDate.year, _minDate.month + index); - final weekDays = date.datesOfWeek(start: widget.startDay); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: _width, - child: Row( - children: List.generate( - 7, - (index) => Expanded( - child: SizedBox( - width: _cellWidth, - child: - _weekBuilder(weekDays[index].weekday - 1), - ), - ), - ), - ), - ), - Expanded( - child: LayoutBuilder(builder: (context, constraints) { - final _cellAspectRatio = - widget.useAvailableVerticalSpace - ? calculateCellAspectRatio( - constraints.maxHeight, - ) - : widget.cellAspectRatio; - - return SizedBox( - height: _height, - width: _width, - child: _MonthPageBuilder( - key: ValueKey(date.toIso8601String()), - onCellTap: widget.onCellTap, - onDateLongPress: widget.onDateLongPress, - width: _width, - height: _height, - controller: controller, - borderColor: widget.borderColor, - borderSize: widget.borderSize, - cellBuilder: _cellBuilder, - cellRatio: _cellAspectRatio, - date: date, - showBorder: widget.showBorder, - startDay: widget.startDay, - ), - ); - }), + Container( + width: _width, + child: Row( + children: List.generate( + 7, + (index) { + final date = + DateTime(_minDate.year, _minDate.month + index); + final weekDays = date.datesOfWeek(start: widget.startDay); + + return Expanded( + child: SizedBox( + width: _cellWidth, + child: _weekBuilder(weekDays[index].weekday - 1), ), - ], - ); - }, - itemCount: _totalMonths, + ); + }, + ), ), ), + Flexible( + fit: widget.hideExtraWeek ? FlexFit.loose : FlexFit.tight, + child: widget.hideExtraWeek + ? SingleChildScrollView( + child: ExpandablePageView.builder( + controller: _pageController, + onPageChanged: _onPageChange, + itemBuilder: _itemBuilder, + itemCount: _totalMonths, + ), + ) + : PageView.builder( + controller: _pageController, + onPageChanged: _onPageChange, + itemBuilder: _itemBuilder, + itemCount: _totalMonths, + ), + ), ], ), ), ); } + Widget _itemBuilder(_, int index) { + final date = DateTime(_minDate.year, _minDate.month + index); + return LayoutBuilder(builder: (context, constraints) { + final _cellAspectRatio = widget.useAvailableVerticalSpace + ? calculateCellAspectRatio( + constraints.maxHeight, + ) + : widget.cellAspectRatio; + + return SizedBox( + height: widget.hideExtraWeek ? null : _height, + width: _width, + child: _MonthPageBuilder( + key: ValueKey(date.toIso8601String()), + onCellTap: widget.onCellTap, + onDateLongPress: widget.onDateLongPress, + width: _width, + height: _height, + controller: controller, + borderColor: widget.borderColor, + borderSize: widget.borderSize, + cellBuilder: _cellBuilder, + cellRatio: _cellAspectRatio, + date: date, + showBorder: widget.showBorder, + startDay: widget.startDay, + hideExtraWeek: widget.hideExtraWeek, + ), + ); + }); + } + /// Returns [EventController] associated with this Widget. /// /// This will throw [AssertionError] if controller is called before its @@ -588,6 +602,7 @@ class _MonthPageBuilder extends StatelessWidget { final CellTapCallback? onCellTap; final DatePressCallback? onDateLongPress; final WeekDays startDay; + final bool hideExtraWeek; const _MonthPageBuilder({ Key? key, @@ -603,14 +618,22 @@ class _MonthPageBuilder extends StatelessWidget { required this.onCellTap, required this.onDateLongPress, required this.startDay, + this.hideExtraWeek = false, }) : super(key: key); @override Widget build(BuildContext context) { final monthDays = date.datesOfMonths(startDay: startDay); + final weekCount = hideExtraWeek + ? monthDays[Constants.monthHasFiveWeek].month == date.month + ? Constants.monthHasSixWeek + : monthDays[Constants.monthHasFourWeek].month == date.month + ? Constants.monthHasFiveWeek + : Constants.monthHasFourWeek + : Constants.monthHasSixWeek; return Container( width: width, - height: height, + // height: height, child: GridView.builder( padding: EdgeInsets.zero, physics: ClampingScrollPhysics(), @@ -618,7 +641,7 @@ class _MonthPageBuilder extends StatelessWidget { crossAxisCount: 7, childAspectRatio: cellRatio, ), - itemCount: 42, + itemCount: weekCount, shrinkWrap: true, itemBuilder: (context, index) { final events = controller.getEventsOnDay(monthDays[index]);