Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve YaruPageIndicator #666

Merged
merged 4 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions example/lib/pages/page_indicator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,28 @@ class _PageIndicatorPageState extends State<PageIndicatorPage> {
length: _length,
page: _page,
onTap: (page) => setState(() => _page = page),
dotSize: _dotSize,
dotSpacing: _dotSpacing,
itemSizeBuilder: (_, __, ___) => Size.square(_dotSize + 8),
layoutDelegate:
YaruPageIndicatorSteppedDelegate(baseItemSpacing: _dotSpacing),
itemBuilder: (index, selectedIndex, length) => YaruPageIndicatorItem(
selected: index == selectedIndex,
size: Size.square(index == selectedIndex ? _dotSize + 8 : _dotSize),
animationDuration: const Duration(milliseconds: 250),
),
),
const SizedBox(height: 15),
YaruPageIndicator(
length: _length,
page: _page,
onTap: (page) => setState(() => _page = page),
itemSizeBuilder: (_, __, ___) => Size.square(_dotSize + 8),
layoutDelegate:
YaruPageIndicatorSteppedDelegate(baseItemSpacing: _dotSpacing),
itemBuilder: (index, selectedIndex, length) => YaruPageIndicatorItem(
selected: index <= selectedIndex,
size: Size.square(index <= selectedIndex ? _dotSize + 8 : _dotSize),
animationDuration: const Duration(milliseconds: 250),
),
),
const SizedBox(height: 15),
ButtonBar(
Expand Down
9 changes: 7 additions & 2 deletions lib/src/widgets/yaru_carousel.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:yaru_icons/yaru_icons.dart';

Expand Down Expand Up @@ -118,9 +119,13 @@ class _YaruCarouselState extends State<YaruCarousel> {
YaruPageIndicator(
length: widget.children.length,
page: _page,
animationDuration: _controller.scrollAnimationDuration,
animationCurve: _controller.scrollAnimationCurve,
onTap: (page) => _controller.animateToPage(page),
itemBuilder: (index, selectedIndex, length) =>
YaruPageIndicatorItem(
selected: index == selectedIndex,
animationDuration: _controller.scrollAnimationDuration,
animationCurve: _controller.scrollAnimationCurve,
),
)
]
],
Expand Down
266 changes: 159 additions & 107 deletions lib/src/widgets/yaru_page_indicator.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import 'package:flutter/material.dart';

import 'yaru_carousel.dart';
import 'yaru_page_indicator_layout_delegate.dart';
import 'yaru_page_indicator_theme.dart';

typedef YaruDotDecorationBuilder = Decoration Function(
typedef YaruPageIndicatorItemBuilder<T> = T Function(
int index,
int selectedIndex,
int length,
);

typedef YaruPageIndicatorTextBuilder = Widget Function(
int page,
int length,
);

/// A responsive page indicator.
///
/// If there's enough space, it will be rendered into a line of dots,
Expand All @@ -23,13 +29,13 @@ class YaruPageIndicator extends StatelessWidget {
super.key,
required this.length,
required this.page,
this.animationDuration,
this.animationCurve,
this.onTap,
this.dotSize,
this.dotSpacing,
this.dotDecorationBuilder,
this.itemSizeBuilder,
this.itemBuilder,
this.mouseCursor,
this.textBuilder,
this.textStyle,
this.layoutDelegate,
}) : assert(page >= 0 && page <= length - 1);

/// Determine the number of pages.
Expand All @@ -39,136 +45,182 @@ class YaruPageIndicator extends StatelessWidget {
/// This value should be clamped between 0 and [length] - 1
final int page;

/// Duration of a transition between two dots.
/// Use [Duration.zero] (defaults) to disable transition.
///
/// Defaults to [Duration.zero].
final Duration? animationDuration;

/// Curve used in a transition between two dots.
///
/// Defaults to [Curves.linear].
final Curve? animationCurve;

/// Callback called when tapping a dot.
/// It passes the tapped page index as parameter.
final ValueChanged<int>? onTap;

/// Size of the dots.
/// Returns the [Size] of a given item.
/// These values are used to compute the layout using [layoutDelegate].
/// If you want an animated items size, just return the largest bounds.
///
/// Defaults to 12.0
final double? dotSize;
/// Defaults to a constant 12.0 square.
final YaruPageIndicatorItemBuilder<Size>? itemSizeBuilder;

/// Base length for the space between the dots.
/// Will be automatically reduced to fit the vertical constraints.
/// Returns the [Widget] of a given item.
///
/// Defaults to 48.0
final double? dotSpacing;

/// Decoration of the dots.
final YaruDotDecorationBuilder? dotDecorationBuilder;
/// Defaults to [YaruPageIndicatorItem].
final YaruPageIndicatorItemBuilder<Widget>? itemBuilder;

/// The cursor for a mouse pointer when it enters or is hovering over the widget.
final MouseCursor? mouseCursor;

/// Returns the [Widget] of the text based indicator.
/// Be careful to use something small enough to fit in a small vertical constraints.
///
/// Defaults to a basic [Text] like "2/12".
/// You can custimize the text style with [textStyle].
final YaruPageIndicatorTextBuilder? textBuilder;

/// Text style used to customize the default text based indicator.
/// Useless if you set a custom [textBuilder];
///
/// Defaults to [TextTheme.bodySmall].
final TextStyle? textStyle;

/// Controls the items spacing, depending on the vertical constraints.
///
/// Defaults to [YaruPageIndicatorSteppedDelegate].
final YaruPageIndicatorLayoutDelegate? layoutDelegate;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final indicatorTheme = YaruPageIndicatorTheme.of(context);

final dotSize = this.dotSize ?? indicatorTheme?.dotSize ?? 12.0;
final dotSpacing = this.dotSpacing ?? indicatorTheme?.dotSpacing ?? 48.0;

return LayoutBuilder(
builder: (context, constraints) {
for (final layout in [
[dotSpacing, constraints.maxWidth / 2],
[dotSpacing / 2, constraints.maxWidth / 3 * 2],
[dotSpacing / 4, constraints.maxWidth / 6 * 5]
]) {
final dotSpacing = layout[0];
final maxWidth = layout[1];

if (dotSize * length + dotSpacing * (length - 1) < maxWidth) {
return _buildDotIndicator(
theme,
indicatorTheme,
dotSize,
dotSpacing,
);
}
}

return _buildTextIndicator(theme);
},
);
}

Widget _buildDotIndicator(
ThemeData theme,
YaruPageIndicatorThemeData? indicatorTheme,
double dotSize,
double dotSpacing,
) {
final dotDecorationBuilder =
this.dotDecorationBuilder ?? indicatorTheme?.dotDecorationBuilder;
final animationDuration = this.animationDuration ??
indicatorTheme?.animationDuration ??
Duration.zero;
final animationCurve =
this.animationCurve ?? indicatorTheme?.animationCurve ?? Curves.linear;
final layoutDelegate = this.layoutDelegate ??
indicatorTheme?.layoutDelegate ??
YaruPageIndicatorSteppedDelegate();
final itemSizeBuilder = this.itemSizeBuilder ??
indicatorTheme?.itemSizeBuilder ??
(_, __, ___) => const Size.square(12.0);
final itemBuilder = this.itemBuilder ??
indicatorTheme?.itemBuilder ??
(index, selectedIndex, _) =>
YaruPageIndicatorItem(selected: selectedIndex == index);
final states = {
if (onTap == null) MaterialState.disabled,
};
final mouseCursor =
MaterialStateProperty.resolveAs(this.mouseCursor, states) ??
indicatorTheme?.mouseCursor?.resolve(states) ??
MaterialStateMouseCursor.clickable.resolve(states);
final textStyle = this.textStyle ??
indicatorTheme?.textStyle ??
theme.textTheme.bodySmall;
final textBuilder = this.textBuilder ??
indicatorTheme?.textBuilder ??
(page, length) => Text(
'$page/$length',
style: textStyle,
textAlign: TextAlign.center,
);

return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(length, (index) {
final dotDecoration = dotDecorationBuilder != null
? dotDecorationBuilder.call(index, page, length)
: BoxDecoration(
color: page == index
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(.3),
shape: BoxShape.circle,
);

return GestureDetector(
onTap: onTap == null ? null : () => onTap!(index),
child: Padding(
padding: EdgeInsets.only(left: index != 0 ? dotSpacing : 0),
child: MouseRegion(
cursor: mouseCursor,
child: animationDuration == Duration.zero
? Container(
width: dotSize,
height: dotSize,
decoration: dotDecoration,
)
: AnimatedContainer(
duration: animationDuration,
curve: animationCurve,
width: dotSize,
height: dotSize,
decoration: dotDecoration,
final itemSizes = <Size>[];
var maxHeight = 0.0;
var maxWidth = 0.0;

for (var i = 0; i < length; i++) {
itemSizes.add(itemSizeBuilder(i, page, length));

maxWidth = itemSizes[i].width > maxWidth ? itemSizes[i].width : maxWidth;
maxHeight =
itemSizes[i].height > maxHeight ? itemSizes[i].height : maxHeight;
}

return LayoutBuilder(
builder: (context, constraints) {
final itemSpacing = layoutDelegate.calculateItemsSpacing(
allItemsWidth: maxWidth * length,
length: length,
availableWidth: constraints.maxWidth,
);

if (null == itemSpacing) {
return textBuilder(page + 1, length);
}

return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(length, (index) {
return Padding(
padding: EdgeInsets.only(left: index != 0 ? itemSpacing : 0),
child: SizedBox(
width: itemSizes[index].width,
height: itemSizes[index].height,
child: Center(
child: GestureDetector(
onTap: onTap == null ? null : () => onTap!(index),
child: MouseRegion(
cursor: mouseCursor,
child: itemBuilder(index, page, length),
),
),
),
),
),
),
);
}),
);
}),
},
);
}
}

/// Default item used in [YaruPageIndicator.itemBuilder].
/// Looks like a simple dot grey when unselected, and accented when selected.
class YaruPageIndicatorItem extends StatelessWidget {
/// Default item used in [YaruPageIndicator.itemBuilder].
/// Looks like a simple dot grey when unselected, and accented when selected.
const YaruPageIndicatorItem({
super.key,
required this.selected,
this.size,
this.animationDuration,
this.animationCurve,
});

/// Define if this is a selected item.
final bool selected;

/// Optionnal item size.
final Size? size;

/// Duration of a transition between two items.
/// Use [Duration.zero] to disable transition.
///
/// Defaults to [Duration.zero].
final Duration? animationDuration;

/// Curve used in a transition between two items.
///
/// Defaults to [Curves.linear].
final Curve? animationCurve;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);

Widget _buildTextIndicator(ThemeData theme) {
return Text(
'${page + 1}/$length',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
final decoration = BoxDecoration(
color: selected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(.3),
shape: BoxShape.circle,
);
final animationDuration = this.animationDuration ?? Duration.zero;
final animationCurve = this.animationCurve ?? Curves.linear;

return animationDuration != Duration.zero
? AnimatedContainer(
width: size?.width,
height: size?.height,
duration: animationDuration,
curve: animationCurve,
decoration: decoration,
)
: Container(
width: size?.width,
height: size?.height,
decoration: decoration,
);
}
}
Loading