diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a46380..c85d60e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [2.0.0] - 2020-07-02 + +**Features** +- refactor sticky item layout calculation +- split alignment param on main and cross axis alignment +- added padding property to `InfiniteListItem` +- added relative positioning for header + [#19](https://github.com/TatsuUkraine/flutter_sticky_infinite_list/issues/19) + +**Release contains breaking changes, see MIGRATION.md for more details** + ## [1.3.0] - 2020-04-28 **Features** diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..2d4dbb9 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,47 @@ +# Migration guide + +## Migration From v1.x.x to v2.x.x + +### Child count params + +In `InfiniteList` next params for max child count was renamed: +- `minChildCount` was renamed to `negChildCount` and now it works with + positive numbers +- `maxChildCount` was renamed to `posChildCount` and, as before, it + works with positive numbers + +### Header alignment + +Param `headerAlignment` in `InfiniteListItem` was replaced with 2 +params: `mainAxisAlignment` and `crossAxisAlignment` + +Main axis is placed with scroll direction: vertical or horizontal. + +With `mainAxisAlignment: HeaderMainAxisAlignment.start` and vertical +scroll header will stick to the top edge, with horizontal scroll - to +the left edge. Similar with `mainAxisAlignment: +HeaderMainAxisAlignment.end` - bottom and right side respectively. + +`crossAxisAlignment` doesn't affect stick side. It just places header to +the left or right side for the vertical scroll, and top or bottom - for +horizontal scroll. + +New parameter was added for relative positioning: `positionAxis` which +defines what direction should be used during layout - column or row. + +### List item layout + +Comparing to v1, v2 by default uses relative positioning. + +To make header overlay content use constructor `overlay`. It's available +in both `InfiniteListItem` and `StickyListItem` widgets. + +### Initial header render + +In default constructor param `initialHeaderBuild` was removed. + +Since default constructor uses relative positioning, header is required +to calculate appropriate item size. + +`initialHeaderBuild` is still available in `overlay` constructors and +affects header render like it was before in v1.x.x diff --git a/README.md b/README.md index 6bccbf8..c97f375 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,14 @@ benefits for performance that Flutter provides. - dynamic header build on content scroll - dynamic min offset calculation on content scroll +## Migration guide + +If you using older MAJOR versions, please +[visit this migration guide](https://github.com/TatsuUkraine/flutter_sticky_infinite_list/blob/master/MIGRATION.md) + ## Demo - + ## Getting Started @@ -88,6 +93,38 @@ class Example extends StatelessWidget { } ``` +Or with header overlay content +```dart + +import 'package:sticky_infinite_list/sticky_infinite_list.dart'; + +class Example extends StatelessWidget { + + @override + Widget build(BuildContext context) { + return InfiniteList( + builder: (BuildContext context, int index) { + /// Builder requires [InfiniteList] to be returned + return InfiniteListItem.overlay( + /// Header builder + headerBuilder: (BuildContext context) { + return Container( + ///... + ); + }, + /// Content builder + contentBuilder: (BuildContext context) { + return Container( + ///... + ); + }, + ); + } + ); + } +} +``` + ### State When min offset callback invoked or header builder is invoked @@ -163,21 +200,19 @@ InfiniteList( /// If you need infinite list in both directions use `InfiniteListDirection.multi` direction: InfiniteListDirection.multi, - /// Min child count. + /// Negative max child count. /// /// Will be used only when `direction: InfiniteListDirection.multi` /// - /// Accepts negative values only - /// /// If it's not provided, scroll will be infinite in negative direction - minChildCount: -100, + negChildCount: 100, - /// Max child count + /// Positive max child count /// /// Specifies number of elements for forward list /// /// If it's not provided, scroll will be infinite in positive direction - maxChildCount: 100, + posChildCount: 100, /// ScrollView anchor value. anchor: 0.0, @@ -230,18 +265,32 @@ InfiniteListItem( /// to define when header should be stick to the bottom of /// content. /// - /// If this method not provided or it returns `0`, + /// If this method returns `0`, /// header will be in sticky state until list item /// will be visible inside view port + /// + /// If this method not provided or it returns null, header + /// will be sticky until offset equals to + /// header size minOffsetProvider: (StickyState state) {}, - /// Header alignment - /// - /// Use [HeaderAlignment] to align header to left, - /// right, top or bottom side - /// - /// Optional. Default value [HeaderAlignment.topLeft] - headerAlignment: HeaderAlignment.topLeft, + /// Header alignment against main axis direction + /// + /// See [HeaderMainAxisAlignment] for more info + HeaderMainAxisAlignment mainAxisAlignment: HeaderMainAxisAlignment.start, + + /// Header alignment against cross axis direction + /// + /// See [HeaderCrossAxisAlignment] for more info + HeaderCrossAxisAlignment crossAxisAlignment: HeaderCrossAxisAlignment.start, + + /// Header position against scroll axis for relative positioned headers + /// + /// Only for relative header positioning + HeaderPositionAxis positionAxis: HeaderPositionAxis.mainAxis, + + /// List item padding, see [EdgeInsets] for more info + EdgeInsets padding: const EdgeInsets.all(8.0), /// Scroll direction /// @@ -257,21 +306,31 @@ InfiniteListItem( #### Header alignment demo - +Relative positioning + + + +Relative cross axis positioning + + + +Overlay positioning + + #### Horizontal scroll demo - + ### Reverse infinite scroll Currently package doesn't support `CustomScrollView.reverse` option. But same result can be achieved with defining `anchor = 1` and -`maxChildCount = 0`. In that way viewport center will be stick -to the bottom and positive list won't render anything. +`posChildCount = 0`. In that way viewport center will be stick to the +bottom and positive list won't render anything. -Additionally you can specify `headerAlignment` to any side. +Additionally you can specify header alignment to any side. ```dart import 'package:sticky_infinite_list/sticky_infinite_list.dart'; @@ -285,13 +344,14 @@ class Example extends StatelessWidget { direction: InfiniteListDirection.multi, - maxChildCount: 0, + posChildCount: 0, builder: (BuildContext context, int index) { /// Builder requires [InfiniteList] to be returned return InfiniteListItem( - headerAlignment: HeaderAlignment.bottomLeft, + mainAxisAlignment: HeaderMainAxisAlignment.end, + crossAxisAlignment: HeaderCrossAxisAlignment.start, /// Header builder headerBuilder: (BuildContext context) { @@ -314,7 +374,7 @@ class Example extends StatelessWidget { #### Demo - + For more info take a look at [Example](https://github.com/TatsuUkraine/flutter_sticky_infinite_list_example) project @@ -330,14 +390,30 @@ Luckily you can extend and override base `InfiniteListItem` class ```dart /// Generic `I` is index type, by default list item uses `int` -class SomeCustomListItem extends InfiniteListItem { - /// Header alignment - /// - /// Supports all sides alignment, see [HeaderAlignment] for more info - /// - /// By default [HeaderAlignment.topLeft] - final HeaderAlignment headerAlignment; - + +class SomeCustomListItem extends InfiniteListItem { + /// Header alignment against main axis direction + /// + /// See [HeaderMainAxisAlignment] for more info + @override + final HeaderMainAxisAlignment mainAxisAlignment = HeaderMainAxisAlignment.start; + + /// Header alignment against cross axis direction + /// + /// See [HeaderCrossAxisAlignment] for more info + @override + final HeaderCrossAxisAlignment crossAxisAlignment = HeaderCrossAxisAlignment.start; + + /// Header position against scroll axis for relative positioned headers + /// + /// See [HeaderPositionAxis] for more info + @override + final HeaderPositionAxis positionAxis = HeaderPositionAxis.mainAxis; + + /// If header should overlay content or not + @override + final bool overlayContent = false; + /// Let item builder know if it should watch /// header position changes /// @@ -345,7 +421,7 @@ class SomeCustomListItem extends InfiniteListItem { /// each time header position changes @override bool get watchStickyState => true; - + /// Let item builder know that this class /// provides header /// @@ -353,7 +429,7 @@ class SomeCustomListItem extends InfiniteListItem { /// and never called @override bool get hasStickyHeader => true; - + /// This methods builds header /// /// If [watchStickyState] is `true`, @@ -366,24 +442,24 @@ class SomeCustomListItem extends InfiniteListItem { /// Also in that case `state` will be `null` @override Widget buildHeader(BuildContext context, [StickyState state]) {} - + /// Content item builder /// /// This method invoked only once @override - Widget buildContent(BuildContext context) => {} + Widget buildContent(BuildContext context) {} - /// Called during init state (see Statefull widget [State.initState]) + /// Called during init state (see [Statefull] widget [State.initState]) /// - /// For additional information about Statefull widget `initState` + /// For additional information about [Statefull] widget `initState` /// lifecycle - see Flutter docs @protected @mustCallSuper void initState() {} - /// Called during item dispose (see Statefull widget [State.dispose]) + /// Called during item dispose (see [Statefull] widget [State.dispose]) /// - /// For additional information about Statefull widget `dispose` + /// For additional information about [Statefull] widget `dispose` /// lifecycle - see Flutter docs @protected @mustCallSuper @@ -393,11 +469,9 @@ class SomeCustomListItem extends InfiniteListItem { #### Need more override?.. -**If you get any problems with this type of override, - please create an issue** - Alongside with list item override, to use inside `InfiniteList` builder, -you can also use `StickyListItem`, that exposed by this package too, independently. +you can also use or extend `StickyListItem`, that exposed by this +package too, independently. This class uses `Stream` to inform it's parent about header position changes @@ -419,12 +493,24 @@ Widget build(BuildContext context) { child: Placeholder(), ), StickyListItem( + streamSink: _headerStream.sink, /// stream to update header during scroll header: Container( - height: 30, + height: _headerHeight, width: double.infinity, color: Colors.orange, child: Center( - child: Text('Sticky Header') + child: StreamBuilder>( + stream: _headerStream.stream, /// stream to update header during scroll + builder: (_, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + + final position = (snapshot.data.position * 100).round(); + + return Text('Positioned relative. Position: $position%'); + }, + ), ), ), content: Container( @@ -432,7 +518,35 @@ Widget build(BuildContext context) { color: Colors.blueAccent, child: Placeholder(), ), - itemIndex: 'single-child-index', + itemIndex: "single-child", + ), + StickyListItem.overlay( + streamSink: _headerOverlayStream.sink, /// stream to update header during scroll + header: Container( + height: _headerHeight, + width: double.infinity, + color: Colors.orange, + child: Center( + child: StreamBuilder>( + stream: _headerOverlayStream.stream, /// stream to update header during scroll + builder: (_, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + + final position = (snapshot.data.position * 100).round(); + + return Text('Positioned overlay. Position: $position%'); + }, + ), + ), + ), + content: Container( + height: height, + color: Colors.lightBlueAccent, + child: Placeholder(), + ), + itemIndex: "single-overlayed-child", ), Container( height: height, @@ -445,12 +559,12 @@ Widget build(BuildContext context) { } ``` -This code will render single child scroll -with 3 widgets. Middle one - item with sticky header. +This code will render single child scroll with 4 widgets. Two middle +items - items with sticky header. **Demo** - + For more complex example please take a look at "Single Example" page in [Example project](https://github.com/TatsuUkraine/flutter_sticky_infinite_list_example) diff --git a/example/example.dart b/example/example.dart index 2850b71..768f61e 100644 --- a/example/example.dart +++ b/example/example.dart @@ -19,14 +19,14 @@ class Example extends StatelessWidget { /// Will be ignored if [direction] is forward /// /// If it's `null`, list will be infinite - minChildCount: -100, + negChildCount: 100, /// Render 100 elements in positive direction. `Optional` /// /// If it's not provided, scroll will be infinite in positive direction /// /// If it's `null`, list will be infinite - maxChildCount: 100, + posChildCount: 100, /// ViewPort anchor value. See [ScrollView] docs for more info anchor: 0.0, @@ -35,11 +35,6 @@ class Example extends StatelessWidget { builder: (BuildContext context, int index) { /// Builder requires [InfiniteList] to be returned return InfiniteListItem( - /// If header should be build during initial render. - /// - /// Will be ignored with just [headerBuilder] builder specified - initialHeaderBuild: true, - /// Header builder with state /// /// Will be invoked each time header changes it's position @@ -92,15 +87,21 @@ class Example extends StatelessWidget { /// of container minOffsetProvider: (StickyState state) => 50, - /// Header alignment + /// Header alignment against main axis /// - /// Currently it supports top left, - /// top right, bottom left and bottom right alignments + /// By default [HeaderMainAxisAlignment.start] + mainAxisAlignment: HeaderMainAxisAlignment.start, + + /// Header alignment against main axis /// - /// By default [HeaderAlignment.topLeft] - headerAlignment: HeaderAlignment.topLeft, + /// By default [HeaderCrossAxisAlignment.start] + crossAxisAlignment: HeaderCrossAxisAlignment.start, + /// Header alignment placement against scroll axis + /// + /// By default [HeaderPositionAxis.mainAxis] + positionAxis: HeaderPositionAxis.mainAxis, ); } ); diff --git a/lib/models/alignments.dart b/lib/models/alignments.dart new file mode 100644 index 0000000..4c9dd0d --- /dev/null +++ b/lib/models/alignments.dart @@ -0,0 +1,63 @@ +/// Header position axis for content without header overflow +enum HeaderPositionAxis { + /// Align against main axis direction + /// + /// For vertical scroll column direction will be used, for + /// horizontal scroll - row + mainAxis, + + /// Align against cross axis direction + /// + /// For vertical scroll row direction will be used, for + /// horizontal scroll - column + crossAxis, +} + +/// Main axis direction alignment +/// +/// For vertical scroll, header will be aligned to the top and bottom edges. +/// For horizontal scroll header will be aligned to the left and right edges +/// +/// It also affect side where sticky header will be sticked. +enum HeaderMainAxisAlignment { + /// Start position against main axis + /// + /// For Horizontal scroll header will be places at the left, + /// for vertical - at the top side + start, + + /// End alignment + /// + /// For Horizontal scroll header will be places at the right, + /// for vertical - at the bottom side + end, +} + +/// Cross axis header alignment +enum HeaderCrossAxisAlignment { + /// Start position against cross axis + /// + /// For Horizontal scroll header will be places at top, + /// for vertical - at the left side + start, + + /// Center position against cross axis + /// + /// This value can be used only with overlay headers, + /// or with relative header and [HeaderPositionAxis.mainAxis] + center, + + /// End position against cross axis + /// + /// For Horizontal scroll header will be places at the bottom, + /// for vertical - at the right side + end, +} + +enum InfiniteListDirection { + /// Render only positive infinite list + single, + + /// Render both positive and negative infinite lists + multi, +} \ No newline at end of file diff --git a/lib/state.dart b/lib/models/sticky_state.dart similarity index 50% rename from lib/state.dart rename to lib/models/sticky_state.dart index 26adc67..6fdd26a 100644 --- a/lib/state.dart +++ b/lib/models/sticky_state.dart @@ -1,47 +1,3 @@ -import 'package:flutter/widgets.dart'; - -typedef Widget ContentBuilder(BuildContext context); -typedef Widget HeaderStateBuilder( - BuildContext context, StickyState state); -typedef Widget HeaderBuilder(BuildContext context); -typedef double MinOffsetProvider(StickyState state); - -/// List direction variants -enum InfiniteListDirection { - /// Render only positive infinite list - single, - - /// Render both positive and negative infinite lists - multi, -} - -/// Alignment options -/// -/// [HeaderAlignment.bottomLeft], [HeaderAlignment.bottomRight] and -/// [HeaderAlignment.bottomCenter] header will be positioned -/// against content bottom edge for vertical scroll -/// -/// [HeaderAlignment.topRight], [HeaderAlignment.bottomRight] and -/// [HeaderAlignment.canterRight] header will be positioned -/// against content right edge for horizontal scroll -/// -/// Which also means that headers will become sticky, when content -/// bottom edge (or right edge for horizontal) will -/// go outside of ViewPort bottom (right for horizontal) edge -/// -/// It also affects on [StickyState.offset] value, since in that case -/// hidden size will be calculated against bottom edges -enum HeaderAlignment { - topLeft, - topCenter, - topRight, - bottomLeft, - bottomCenter, - bottomRight, - centerLeft, - centerRight, -} - /// Sticky state object /// that describes header position and content height class StickyState { @@ -53,13 +9,15 @@ class StickyState { /// /// `1.0` - max end position /// - /// If [InfiniteListItem.initialHeaderBuild] is true, initial - /// header render will be with position = 0 + /// If [InfiniteListItem.initialHeaderBuild] is true with [InfiniteListItem.overlay], + /// or default [InfiniteListItem] constructor is used, + /// initial header render will be with position = 0 final double position; /// Number of pixels, that outside of viewport /// - /// If [InfiniteListItem.initialHeaderBuild] is true, initial + /// If [InfiniteListItem.initialHeaderBuild] is true with [InfiniteListItem.overlay], + /// or default [InfiniteListItem] constructor is used, /// header render will be with offset = 0 /// /// For header bottom positions (or right positions for horizontal) @@ -83,8 +41,9 @@ class StickyState { /// Scroll item height. /// - /// If [InfiniteListItem.initialHeaderBuild] is true, initial - /// header render will be called without this value + /// If [InfiniteListItem.initialHeaderBuild] is true with [InfiniteListItem.overlay], + /// or default [InfiniteListItem] constructor is used, + /// initial header render will be called without this value final double contentSize; StickyState(this.index, { @@ -107,4 +66,4 @@ class StickyState { sticky: sticky ?? this.sticky, contentSize: contentHeight ?? this.contentSize, ); -} +} \ No newline at end of file diff --git a/lib/models/types.dart b/lib/models/types.dart new file mode 100644 index 0000000..2690c6b --- /dev/null +++ b/lib/models/types.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +import 'sticky_state.dart'; + +typedef Widget ContentBuilder(BuildContext context); +typedef Widget HeaderStateBuilder(BuildContext context, StickyState state); +typedef Widget HeaderBuilder(BuildContext context); +typedef double MinOffsetProvider(StickyState state); \ No newline at end of file diff --git a/lib/render.dart b/lib/render.dart index a639a66..074ab8c 100644 --- a/lib/render.dart +++ b/lib/render.dart @@ -3,13 +3,22 @@ import 'dart:math' show max, min; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -import './state.dart'; +import 'models/alignments.dart'; +import 'models/sticky_state.dart'; +import 'models/types.dart'; + + +/// Sticky item render object based on [RenderStack] class StickyListItemRenderObject extends RenderStack { ScrollableState _scrollable; StreamSink> _streamSink; I _itemIndex; MinOffsetProvider _minOffsetProvider; + bool _overlayContent; + HeaderPositionAxis _positionAxis; + HeaderMainAxisAlignment _mainAxisAlignment; + HeaderCrossAxisAlignment _crossAxisAlignment; double _lastOffset; bool _headerOverflow = false; @@ -19,18 +28,24 @@ class StickyListItemRenderObject extends RenderStack { @required I itemIndex, MinOffsetProvider minOffsetProvider, StreamSink> streamSink, - AlignmentGeometry alignment, TextDirection textDirection, - StackFit fit, Overflow overflow, + bool overlayContent, + HeaderPositionAxis positionAxis = HeaderPositionAxis.mainAxis, + HeaderMainAxisAlignment mainAxisAlignment = HeaderMainAxisAlignment.start, + HeaderCrossAxisAlignment crossAxisAlignment = HeaderCrossAxisAlignment.start, }) : _scrollable = scrollable, _streamSink = streamSink, _itemIndex = itemIndex, _minOffsetProvider = minOffsetProvider, + _overlayContent = overlayContent, + _positionAxis = positionAxis, + _mainAxisAlignment = mainAxisAlignment, + _crossAxisAlignment = crossAxisAlignment, super( - alignment: alignment, + alignment: _headerAlignment(scrollable, mainAxisAlignment, crossAxisAlignment), textDirection: textDirection, - fit: fit, + fit: StackFit.loose, overflow: overflow, ); @@ -49,13 +64,39 @@ class StickyListItemRenderObject extends RenderStack { } MinOffsetProvider get minOffsetProvider => - _minOffsetProvider ?? (state) => 0; + _minOffsetProvider ?? (state) => null; set minOffsetProvider(MinOffsetProvider offsetProvider) { _minOffsetProvider = offsetProvider; markNeedsPaint(); } + set overlayContent(bool overlayContent) { + _overlayContent = overlayContent; + + if (_overlayContent != overlayContent) { + markNeedsLayout(); + } + } + + set positionAxis(HeaderPositionAxis positionAxis) { + _positionAxis = positionAxis; + + if (_positionAxis != positionAxis) { + markNeedsLayout(); + } + } + + set mainAxisAlignment(HeaderMainAxisAlignment axisAlignment) { + _mainAxisAlignment = axisAlignment; + alignment = _headerAlignment(scrollable, _mainAxisAlignment, _crossAxisAlignment); + } + + set crossAxisAlignment(HeaderCrossAxisAlignment axisAlignment) { + _crossAxisAlignment = axisAlignment; + alignment = _headerAlignment(scrollable, _mainAxisAlignment, _crossAxisAlignment); + } + ScrollableState get scrollable => _scrollable; set scrollable(ScrollableState newScrollable) { @@ -105,16 +146,37 @@ class StickyListItemRenderObject extends RenderStack { } } + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + final RenderBox header = _headerBox; + + final BoxConstraints containerConstraints = constraints.loosen(); + + header.layout(containerConstraints, parentUsesSize: true); + + size = _layoutContent(containerConstraints, header.size); + + assert(size.width == constraints.constrainWidth(size.width)); + assert(size.height == constraints.constrainHeight(size.height)); + + assert(size.isFinite); + + final StackParentData headerParentData = header.parentData as StackParentData; + + headerParentData.offset = alignment.resolve(TextDirection.ltr).alongOffset(size - header.size as Offset); + } + void updateHeaderOffset() { _headerOverflow = false; final double stuckOffset = _stuckOffset; final StackParentData parentData = _headerBox.parentData; - final double contentSize = _getContentDirectionSize(); - final double headerSize = _getHeaderDirectionSize(); + final double contentSize = _contentDirectionSize; + final double headerSize = _headerDirectionSize; - final double offset = _getStateOffset(stuckOffset, contentSize); + final double offset = _calculateStateOffset(stuckOffset, contentSize); final double position = offset / contentSize; final StickyState state = StickyState( @@ -124,14 +186,14 @@ class StickyListItemRenderObject extends RenderStack { contentSize: contentSize, ); - final double headerOffset = _getHeaderOffset( + final double headerOffset = _calculateHeaderOffset( contentSize, stuckOffset, headerSize, minOffsetProvider(state) ); - parentData.offset = _getDirectionalOffset( + parentData.offset = _headerDirectionalOffset( parentData.offset, headerOffset ); @@ -145,7 +207,7 @@ class StickyListItemRenderObject extends RenderStack { sticky: _isSticky( state, headerOffset, - _getHeaderOffset( + _calculateHeaderOffset( contentSize, stuckOffset, headerSize @@ -155,25 +217,62 @@ class StickyListItemRenderObject extends RenderStack { } } - bool get _scrollDirectionVertical => - [AxisDirection.up, AxisDirection.down].contains(scrollable.axisDirection); + @override + double computeMinIntrinsicWidth(double height) { + if ( + _overlayContent || + _scrollDirectionVertical && _positionAxis == HeaderPositionAxis.mainAxis || + !_scrollDirectionVertical && _positionAxis == HeaderPositionAxis.crossAxis + ) { + return _contentBox.getMinIntrinsicWidth(height); + } - bool get _alignmentStart { - if (_scrollDirectionVertical) { - return [ - AlignmentDirectional.topStart, - AlignmentDirectional.topCenter, - AlignmentDirectional.topEnd, - ].contains(alignment); + return _contentBox.getMinIntrinsicWidth(height) + _headerBox.getMinIntrinsicWidth(height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + if ( + _overlayContent || + _scrollDirectionVertical && _positionAxis == HeaderPositionAxis.mainAxis || + !_scrollDirectionVertical && _positionAxis == HeaderPositionAxis.crossAxis + ) { + return _contentBox.getMaxIntrinsicWidth(height); + } + + return _contentBox.getMaxIntrinsicWidth(height) + _headerBox.getMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicHeight(double width) { + if ( + _overlayContent || + _scrollDirectionVertical && _positionAxis == HeaderPositionAxis.crossAxis || + !_scrollDirectionVertical && _positionAxis == HeaderPositionAxis.mainAxis + ) { + return _contentBox.getMinIntrinsicHeight(width); + } + + return _contentBox.getMinIntrinsicHeight(width) + _headerBox.getMinIntrinsicHeight(width); + } + + @override + double computeMaxIntrinsicHeight(double width) { + if ( + _overlayContent || + _scrollDirectionVertical && _positionAxis == HeaderPositionAxis.crossAxis || + !_scrollDirectionVertical && _positionAxis == HeaderPositionAxis.mainAxis + ) { + return _contentBox.getMinIntrinsicHeight(width); } - return [ - AlignmentDirectional.topStart, - AlignmentDirectional.bottomStart, - AlignmentDirectional.centerStart, - ].contains(alignment); + return _contentBox.getMinIntrinsicHeight(width) + _headerBox.getMinIntrinsicHeight(width); } + bool get _scrollDirectionVertical => _scrollableAxisVertical(scrollable.axisDirection); + + bool get _alignmentStart => _mainAxisAlignment == HeaderMainAxisAlignment.start; + double get _scrollableSize { final viewportContainer = _viewport; @@ -206,19 +305,15 @@ class StickyListItemRenderObject extends RenderStack { return _viewport.getOffsetToReveal(this, 0).offset - _scrollable.position.pixels - _scrollableSize; } - double _getContentDirectionSize() { - return _scrollDirectionVertical - ? _contentBox.size.height - : _contentBox.size.width; - } + double get _contentDirectionSize => _scrollDirectionVertical + ? size.height + : size.width; - double _getHeaderDirectionSize() { - return _scrollDirectionVertical - ? _headerBox.size.height - : _headerBox.size.width; - } + double get _headerDirectionSize => _scrollDirectionVertical + ? _headerBox.size.height + : _headerBox.size.width; - Offset _getDirectionalOffset(Offset originalOffset, double offset) { + Offset _headerDirectionalOffset(Offset originalOffset, double offset) { if (_scrollDirectionVertical) { return Offset( originalOffset.dx, @@ -232,8 +327,8 @@ class StickyListItemRenderObject extends RenderStack { ); } - double _getStateOffset(double stuckOffset, double contentSize) { - double offset = _getOffset(stuckOffset, 0, contentSize); + double _calculateStateOffset(double stuckOffset, double contentSize) { + double offset = _calculateOffset(stuckOffset, 0, contentSize); if (_alignmentStart) { return offset; @@ -242,26 +337,30 @@ class StickyListItemRenderObject extends RenderStack { return contentSize - offset; } - double _getHeaderOffset( + double _calculateHeaderOffset( double contentSize, double stuckOffset, double headerSize, - [double providedMinOffset = 0] + [double providedMinOffset] ) { - final double minOffset = _getMinOffset(contentSize, providedMinOffset); + if (providedMinOffset == null) { + providedMinOffset = headerSize; + } + + final double minOffset = _calculateMinOffset(contentSize, providedMinOffset); if (_alignmentStart) { - return _getOffset(stuckOffset, 0, minOffset); + return _calculateOffset(stuckOffset, 0, minOffset); } - return _getOffset(stuckOffset, minOffset, contentSize) - headerSize; + return _calculateOffset(stuckOffset, minOffset, contentSize) - headerSize; } - double _getOffset(double current, double minPosition, double maxPosition) { + double _calculateOffset(double current, double minPosition, double maxPosition) { return max(minPosition, min(-current, maxPosition)); } - double _getMinOffset(double contentSize, double minOffset) { + double _calculateMinOffset(double contentSize, double minOffset) { if (_alignmentStart) { return contentSize - minOffset; } @@ -284,4 +383,117 @@ class StickyListItemRenderObject extends RenderStack { state.position < 1 ); } + + Size _layoutContent(BoxConstraints constraints, Size headerSize) { + final RenderBox content = _contentBox; + final StackParentData contentParentData = content.parentData as StackParentData; + contentParentData.offset = Offset.zero; + + if (!_overlayContent) { + if ( + ( + _positionAxis == HeaderPositionAxis.crossAxis && + _scrollDirectionVertical + ) || + ( + _positionAxis == HeaderPositionAxis.mainAxis && + !_scrollDirectionVertical + ) + ) { + content.layout(constraints.copyWith( + maxWidth: constraints.maxWidth - headerSize.width + ), parentUsesSize: true); + + if ( + ( + _crossAxisAlignment == HeaderCrossAxisAlignment.start && + _scrollDirectionVertical + ) || + ( + _mainAxisAlignment == HeaderMainAxisAlignment.start && + !_scrollDirectionVertical + ) + ) { + contentParentData.offset = Offset(headerSize.width, 0); + } + + final Size contentSize = content.size; + + return Size( + contentSize.width + headerSize.width, + contentSize.height + ); + } + + if ( + ( + _positionAxis == HeaderPositionAxis.mainAxis && + _scrollDirectionVertical + ) || + ( + _positionAxis == HeaderPositionAxis.crossAxis && + !_scrollDirectionVertical + ) + ) { + content.layout(constraints.copyWith( + maxHeight: constraints.maxHeight - headerSize.height + ), parentUsesSize: true); + + if ( + ( + _mainAxisAlignment == HeaderMainAxisAlignment.start && + _scrollDirectionVertical + ) || + ( + _crossAxisAlignment == HeaderCrossAxisAlignment.start && + !_scrollDirectionVertical + ) + ) { + contentParentData.offset = Offset(0, headerSize.height); + } + + final Size contentSize = content.size; + + return Size( + contentSize.width, + contentSize.height + headerSize.height + ); + } + } + + content.layout(constraints, parentUsesSize: true); + + return content.size; + } + + static AlignmentGeometry _headerAlignment(ScrollableState scrollable, HeaderMainAxisAlignment mainAxisAlignment, HeaderCrossAxisAlignment crossAxisAlignment) { + final bool vertical = _scrollableAxisVertical(scrollable.axisDirection); + + switch (crossAxisAlignment) { + + case HeaderCrossAxisAlignment.end: + if (mainAxisAlignment == HeaderMainAxisAlignment.end) { + return Alignment.bottomRight; + } + + return vertical ? Alignment.topRight : Alignment.bottomLeft; + + case HeaderCrossAxisAlignment.center: + if (mainAxisAlignment == HeaderMainAxisAlignment.start) { + return vertical ? Alignment.topCenter : Alignment.centerLeft; + } + + return vertical ? Alignment.bottomCenter : Alignment.centerRight; + + case HeaderCrossAxisAlignment.start: + default: + if (mainAxisAlignment == HeaderMainAxisAlignment.start) { + return Alignment.topLeft; + } + + return vertical ? Alignment.bottomLeft : Alignment.topRight; + } + } + + static bool _scrollableAxisVertical(AxisDirection direction) => [AxisDirection.up, AxisDirection.down].contains(direction); } diff --git a/lib/sticky_infinite_list.dart b/lib/sticky_infinite_list.dart index 9b0df2a..76685b6 100644 --- a/lib/sticky_infinite_list.dart +++ b/lib/sticky_infinite_list.dart @@ -2,4 +2,6 @@ library sticky_infinite_list; export './widget.dart'; export './render.dart'; -export './state.dart'; +export './models/alignments.dart'; +export './models/sticky_state.dart'; +export './models/types.dart'; diff --git a/lib/widget.dart b/lib/widget.dart index db025de..259aa7d 100644 --- a/lib/widget.dart +++ b/lib/widget.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -import './state.dart'; + import './render.dart'; +import 'models/sticky_state.dart'; +import 'models/types.dart'; +import 'models/alignments.dart'; typedef InfiniteListItem ItemBuilder(BuildContext context, I index); @@ -13,16 +16,14 @@ typedef InfiniteListItem ItemBuilder(BuildContext context, I index); /// /// This class build item header and content class InfiniteListItem { + /// Header builder based on [StickyState] final HeaderStateBuilder headerStateBuilder; + + /// Header builder final HeaderBuilder headerBuilder; - final ContentBuilder contentBuilder; - /// Header alignment - /// - /// By default [HeaderAlignment.topLeft] - /// - /// For more option take a look in [HeaderAlignment] - final HeaderAlignment headerAlignment; + /// Content builder + final ContentBuilder contentBuilder; /// Function, that provides min offset. /// @@ -53,20 +54,71 @@ class InfiniteListItem { /// /// For case when only [headerBuilder] is defined, /// this property will be ignored + /// + /// If it's relative header positioning ([InfiniteListItem.overlay] constructor is used), + /// this property always will be `true`, which means that with relative + /// positioning, header will be built with basic [StickyState] object. + /// + /// It's required due to layout container and define it's actual dimensions final bool initialHeaderBuild; - InfiniteListItem({ + /// Header alignment against main axis direction. + /// + /// Affects header stick side. + /// + /// See [HeaderMainAxisAlignment] for more info + final HeaderMainAxisAlignment mainAxisAlignment; + + /// Header alignment against cross axis direction + /// + /// See [HeaderCrossAxisAlignment] for more info + final HeaderCrossAxisAlignment crossAxisAlignment; + + /// Header position against scroll axis for relative positioned headers + /// + /// See [HeaderPositionAxis] for more info + final HeaderPositionAxis positionAxis; + + /// List item padding, see [EdgeInsets] for more info + final EdgeInsets padding; + + /// If header should overlay content or not + final bool overlayContent; + + /// Default list item constructor with relative header positioning + const InfiniteListItem({ + @required this.contentBuilder, + this.headerBuilder, + this.headerStateBuilder, + this.minOffsetProvider, + this.mainAxisAlignment = HeaderMainAxisAlignment.start, + this.crossAxisAlignment = HeaderCrossAxisAlignment.start, + this.positionAxis = HeaderPositionAxis.mainAxis, + this.padding, + }): overlayContent = false, + initialHeaderBuild = true; + + /// List item constructor with overlayed header positioning + const InfiniteListItem.overlay({ @required this.contentBuilder, this.headerBuilder, this.headerStateBuilder, this.minOffsetProvider, - this.headerAlignment = HeaderAlignment.topLeft, this.initialHeaderBuild = false, - }); - + this.mainAxisAlignment = HeaderMainAxisAlignment.start, + this.crossAxisAlignment = HeaderCrossAxisAlignment.start, + this.padding, + }) + : positionAxis = HeaderPositionAxis.mainAxis, + overlayContent = true; + + /// Defines if list item has Header bool get hasStickyHeader => headerBuilder != null || headerStateBuilder != null; + /// Defines if list item should watch header position state changes. + /// + /// It's true if [headerStateBuilder] was provided instead of [headerBuilder] bool get watchStickyState => headerStateBuilder != null; /// Header item builder @@ -97,25 +149,23 @@ class InfiniteListItem { @mustCallSuper void dispose() {} - Widget _getHeader(BuildContext context, Stream> stream, I index) { + Widget _buildHeader(BuildContext context, Stream> stream, I index) { assert(hasStickyHeader, "At least one builder should be provided"); if (!watchStickyState) { return buildHeader(context); } - return Positioned( - child: StreamBuilder>( - stream: stream, - initialData: initialHeaderBuild ? StickyState(index) : null, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Container(); - } - - return buildHeader(context, snapshot.data); - }, - ), + return StreamBuilder>( + stream: stream, + initialData: initialHeaderBuild ? StickyState(index) : null, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Container(); + } + + return buildHeader(context, snapshot.data); + }, ); } } @@ -146,15 +196,12 @@ class InfiniteList extends StatefulWidget { final InfiniteListDirection direction; /// Max child count for positive direction list - final int maxChildCount; + final int posChildCount; /// Max child count for negative list direction /// /// Ignored when [direction] is [InfiniteListDirection.single] - /// - /// This value should have negative value in order to provide right calculation - /// for negative list - final int minChildCount; + final int negChildCount; /// Proxy property for [ScrollView.reverse] /// @@ -188,8 +235,8 @@ class InfiniteList extends StatefulWidget { @required this.builder, this.controller, this.direction = InfiniteListDirection.single, - this.maxChildCount, - this.minChildCount, + this.posChildCount, + this.negChildCount, //this.reverse = false, this.anchor = 0.0, this.cacheExtent, @@ -206,28 +253,25 @@ class _InfiniteListState extends State { StreamController _streamController = StreamController>.broadcast(); - int get _reverseChildCount => - widget.minChildCount == null ? null : widget.minChildCount * -1; - SliverList get _reverseList => SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => - _getListItem(context, (index + 1) * -1), - childCount: _reverseChildCount, + _buildListItem(context, (index + 1) * -1), + childCount: widget.negChildCount, ), ); SliverList get _forwardList => SliverList( delegate: SliverChildBuilderDelegate( - _getListItem, - childCount: widget.maxChildCount, + _buildListItem, + childCount: widget.posChildCount, ), key: widget._centerKey, ); - Widget _getListItem(BuildContext context, int index) => + Widget _buildListItem(BuildContext context, int index) => _StickySliverListItem( streamController: _streamController, index: index, @@ -287,39 +331,7 @@ class _StickySliverListItem extends StatefulWidget { }) : super(key: key); @override - State<_StickySliverListItem> createState() => - _StickySliverListItemState(); - - /// Maps sticky header alignment values - /// to [AlignmentDirectional] variant - AlignmentDirectional get alignment { - switch (listItem.headerAlignment) { - case HeaderAlignment.centerLeft: - return AlignmentDirectional.centerStart; - - case HeaderAlignment.centerRight: - return AlignmentDirectional.centerEnd; - - case HeaderAlignment.bottomLeft: - return AlignmentDirectional.bottomStart; - - case HeaderAlignment.bottomCenter: - return AlignmentDirectional.bottomCenter; - - case HeaderAlignment.bottomRight: - return AlignmentDirectional.bottomEnd; - - case HeaderAlignment.bottomCenter: - return AlignmentDirectional.bottomCenter; - - case HeaderAlignment.topRight: - return AlignmentDirectional.topEnd; - - case HeaderAlignment.topLeft: - default: - return AlignmentDirectional.topStart; - } - } + State<_StickySliverListItem> createState() => _StickySliverListItemState(); } class _StickySliverListItemState extends State<_StickySliverListItem> { @@ -331,33 +343,63 @@ class _StickySliverListItemState extends State<_StickySliverListItem> { } @override + Widget build(BuildContext context) { + if (widget.listItem.padding == null) { + return _buildItem(context); + } + + return Padding( + padding: widget.listItem.padding, + child: _buildItem(context), + ); + } + + @override + void dispose() { + super.dispose(); + + widget.listItem.dispose(); + } + + Widget _buildItem(BuildContext context) { final Widget content = widget.listItem.buildContent(context); if (!widget.listItem.hasStickyHeader) { return content; } + if (widget.listItem.overlayContent) { + return StickyListItem.overlay( + itemIndex: widget.index, + streamSink: widget.streamController.sink, + header: widget.listItem._buildHeader( + context, + widget._stream, + widget.index + ), + content: content, + minOffsetProvider: widget.listItem.minOffsetProvider, + mainAxisAlignment: widget.listItem.mainAxisAlignment, + crossAxisAlignment: widget.listItem.crossAxisAlignment, + ); + } + return StickyListItem( itemIndex: widget.index, streamSink: widget.streamController.sink, - header: widget.listItem._getHeader( + header: widget.listItem._buildHeader( context, widget._stream, widget.index ), content: content, minOffsetProvider: widget.listItem.minOffsetProvider, - alignment: widget.alignment, + mainAxisAlignment: widget.listItem.mainAxisAlignment, + crossAxisAlignment: widget.listItem.crossAxisAlignment, + positionAxis: widget.listItem.positionAxis, ); } - - @override - void dispose() { - super.dispose(); - - widget.listItem.dispose(); - } } /// Sticky list item that provides header offset calculation @@ -375,21 +417,72 @@ class StickyListItem extends Stack { /// during stream event emit final I itemIndex; - /// Callback function that tells when header to stick to the bottom + /// Callback function that tells when header to stick to the bottom. + /// + /// If it returns `null` or callback not provided - min offset will be header height final MinOffsetProvider minOffsetProvider; + /// Header alignment against main axis direction + /// + /// Affects header stick side. + /// + /// See [HeaderMainAxisAlignment] for more info + final HeaderMainAxisAlignment mainAxisAlignment; + + /// Header alignment against cross axis direction + /// + /// See [HeaderCrossAxisAlignment] for more info + final HeaderCrossAxisAlignment crossAxisAlignment; + + /// Header position against scroll axis for relative positioned headers + /// + /// See [HeaderPositionAxis] for more info + final HeaderPositionAxis positionAxis; + + /// Defines if header should overlay content + final bool overlayContent; + + /// Default sticky item constructor with relative header positioning StickyListItem({ @required Widget header, @required Widget content, @required this.itemIndex, this.minOffsetProvider, this.streamSink, - AlignmentDirectional alignment, + this.mainAxisAlignment = HeaderMainAxisAlignment.start, + this.crossAxisAlignment = HeaderCrossAxisAlignment.start, + this.positionAxis = HeaderPositionAxis.mainAxis, + Key key, + }) + : overlayContent = false, + assert( + positionAxis == HeaderPositionAxis.mainAxis || crossAxisAlignment != HeaderCrossAxisAlignment.center, + 'Center cross axis alignment can\'t be used with Cross axis positioning' + ), + super( + key: key, + children: [content, header], + overflow: Overflow.clip, + ); + + /// Default sticky item constructor with overlayed header positioning. + /// + /// Header position axis in this case will be against main axis always. + StickyListItem.overlay({ + @required Widget header, + @required Widget content, + @required this.itemIndex, + this.minOffsetProvider, + this.streamSink, + this.mainAxisAlignment = HeaderMainAxisAlignment.start, + this.crossAxisAlignment = HeaderCrossAxisAlignment.start, Key key, - }) : super( + }) + : overlayContent = true, + positionAxis = HeaderPositionAxis.mainAxis, + super( key: key, children: [content, header], - alignment: alignment ?? AlignmentDirectional.topStart, overflow: Overflow.clip, ); @@ -400,9 +493,11 @@ class StickyListItem extends Stack { RenderStack createRenderObject(BuildContext context) => StickyListItemRenderObject( scrollable: _getScrollableState(context), - alignment: alignment, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + positionAxis: positionAxis, textDirection: textDirection ?? Directionality.of(context), - fit: fit, + overlayContent: overlayContent, overflow: overflow, itemIndex: itemIndex, streamSink: streamSink, @@ -419,7 +514,11 @@ class StickyListItem extends Stack { ..scrollable = _getScrollableState(context) ..itemIndex = itemIndex ..streamSink = streamSink - ..minOffsetProvider = minOffsetProvider; + ..minOffsetProvider = minOffsetProvider + ..mainAxisAlignment = mainAxisAlignment + ..crossAxisAlignment = crossAxisAlignment + ..positionAxis = positionAxis + ..overlayContent = overlayContent; } } } diff --git a/pubspec.lock b/pubspec.lock index aebdafa..97283e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,34 +1,62 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.4.1" boolean_selector: dependency: transitive description: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "2.0.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.14.11" + version: "1.14.12" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" flutter: dependency: "direct main" description: flutter @@ -39,20 +67,27 @@ packages: description: flutter source: sdk version: "0.0.0" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.12" matcher: dependency: transitive description: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.5" + version: "0.12.6" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.1.7" + version: "1.1.8" path: dependency: transitive description: @@ -60,20 +95,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.6.4" - pedantic: + petitparser: dependency: transitive description: - name: pedantic + name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "1.8.0+1" + version: "2.4.0" quiver: dependency: transitive description: name: quiver url: "https://pub.dartlang.org" source: hosted - version: "2.0.5" + version: "2.1.3" sky_engine: dependency: transitive description: flutter @@ -85,7 +120,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.5.5" + version: "1.7.0" stack_trace: dependency: transitive description: @@ -120,7 +155,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.5" + version: "0.2.15" typed_data: dependency: transitive description: @@ -135,5 +170,12 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.1" sdks: - dart: ">=2.2.2 <3.0.0" + dart: ">=2.6.0 <3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 0194eb1..1085c22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,8 +5,7 @@ description: >- Can be customized or with config options or with override. -version: 1.3.0 -author: TatsuUkraine +version: 2.0.0 homepage: https://github.com/TatsuUkraine/flutter_sticky_infinite_list repository: https://github.com/TatsuUkraine/flutter_sticky_infinite_list issue_tracker: https://github.com/TatsuUkraine/flutter_sticky_infinite_list/issues