refactor(ui): morph the loading indicator through material_new_shapes
The indicator carried its own approximation of the M3E shape sequence: seven StarBorder specs lerped field by field, so a morph interpolated point counts and rounding rather than the outlines themselves, and the pill and the oval had to be faked with width and height scales. material_new_shapes ships that sequence as RoundedPolygon and morphs between two of them along their real outlines, so the shapes are the M3E ones and the transition follows the path between them. The morphs are built once per polygon list and reused across frames, since building one walks both polygons. polygons now takes RoundedPolygon. Changelog: skip
This commit is contained in:
+47
-157
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
|
||||
@@ -13,7 +15,7 @@ class CommonCircleLoading extends StatefulWidget {
|
||||
final LoadingIndicatorM3EVariant variant;
|
||||
final Color? color;
|
||||
final Color? containerColor;
|
||||
final List<StarBorder>? polygons;
|
||||
final List<RoundedPolygon>? polygons;
|
||||
final BoxConstraints? constraints;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final String? semanticLabel;
|
||||
@@ -47,57 +49,14 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
height: CommonCircleLoading.defaultDimension,
|
||||
);
|
||||
|
||||
static const List<_ShapeSpec> _defaultShapeSequence = [
|
||||
// Soft burst.
|
||||
_ShapeSpec(
|
||||
points: 10,
|
||||
innerRadiusRatio: 0.78,
|
||||
pointRounding: 0.55,
|
||||
valleyRounding: 0.35,
|
||||
),
|
||||
// Nine-sided cookie.
|
||||
_ShapeSpec(
|
||||
points: 9,
|
||||
innerRadiusRatio: 0.86,
|
||||
pointRounding: 0.62,
|
||||
valleyRounding: 0.28,
|
||||
),
|
||||
// Pentagon.
|
||||
_ShapeSpec(points: 5, innerRadiusRatio: 0.81, pointRounding: 0.25),
|
||||
// Horizontal pill.
|
||||
_ShapeSpec(
|
||||
points: 2,
|
||||
innerRadiusRatio: 1,
|
||||
pointRounding: 0.5,
|
||||
valleyRounding: 0.5,
|
||||
widthScale: 0.95,
|
||||
heightScale: 0.52,
|
||||
squash: 1,
|
||||
),
|
||||
// Sunny.
|
||||
_ShapeSpec(
|
||||
points: 8,
|
||||
innerRadiusRatio: 0.58,
|
||||
pointRounding: 0.52,
|
||||
valleyRounding: 0.18,
|
||||
),
|
||||
// Four-sided cookie.
|
||||
_ShapeSpec(
|
||||
points: 4,
|
||||
innerRadiusRatio: 0.78,
|
||||
pointRounding: 0.62,
|
||||
valleyRounding: 0.28,
|
||||
),
|
||||
// Vertical oval.
|
||||
_ShapeSpec(
|
||||
points: 2,
|
||||
innerRadiusRatio: 1,
|
||||
pointRounding: 0.5,
|
||||
valleyRounding: 0.5,
|
||||
widthScale: 0.62,
|
||||
heightScale: 0.95,
|
||||
squash: 1,
|
||||
),
|
||||
static final List<RoundedPolygon> _defaultShapeSequence = [
|
||||
MaterialShapes.softBurst,
|
||||
MaterialShapes.cookie9Sided,
|
||||
MaterialShapes.pentagon,
|
||||
MaterialShapes.pill,
|
||||
MaterialShapes.sunny,
|
||||
MaterialShapes.cookie4Sided,
|
||||
MaterialShapes.oval,
|
||||
];
|
||||
|
||||
final SpringSimulation _morphAnimation = SpringSimulation(
|
||||
@@ -112,6 +71,9 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
late final AnimationController _globalRotationController;
|
||||
late final Listenable _animation;
|
||||
|
||||
List<RoundedPolygon>? _cachedPolygons;
|
||||
List<Morph>? _cachedMorphs;
|
||||
|
||||
var _currentMorphIndex = 0;
|
||||
var _morphRotationTargetAngle = _quarterRotation;
|
||||
|
||||
@@ -161,11 +123,8 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
LoadingIndicatorM3EVariant.contained =>
|
||||
widget.containerColor ?? colorScheme.primaryContainer,
|
||||
};
|
||||
final shapeSequence =
|
||||
widget.polygons
|
||||
?.map(_ShapeSpec.fromStarBorder)
|
||||
.toList(growable: false) ??
|
||||
_defaultShapeSequence;
|
||||
final shapeSequence = widget.polygons ?? _defaultShapeSequence;
|
||||
final morphs = _morphsFor(shapeSequence);
|
||||
final padding = (widget.padding ?? EdgeInsets.zero).resolve(
|
||||
Directionality.of(context),
|
||||
);
|
||||
@@ -205,17 +164,12 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
morphProgress * _quarterRotation +
|
||||
_morphRotationTargetAngle +
|
||||
_globalRotationController.value * _fullRotation;
|
||||
final shape = _ShapeSpec.lerp(
|
||||
shapeSequence[currentMorphIndex],
|
||||
shapeSequence[(currentMorphIndex + 1) %
|
||||
shapeSequence.length],
|
||||
morphProgress,
|
||||
);
|
||||
return Transform.rotate(
|
||||
angle: rotationDegrees * math.pi / 180,
|
||||
child: CustomPaint(
|
||||
painter: _ShapePainter(
|
||||
shape: shape,
|
||||
painter: _MorphPainter(
|
||||
morph: morphs[currentMorphIndex],
|
||||
progress: morphProgress,
|
||||
color: activeColor,
|
||||
scaleFactor: _activeIndicatorScale,
|
||||
),
|
||||
@@ -238,6 +192,18 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
int get _shapeCount =>
|
||||
widget.polygons?.length ?? _defaultShapeSequence.length;
|
||||
|
||||
List<Morph> _morphsFor(List<RoundedPolygon> polygons) {
|
||||
final cachedMorphs = _cachedMorphs;
|
||||
if (cachedMorphs != null && listEquals(_cachedPolygons, polygons)) {
|
||||
return cachedMorphs;
|
||||
}
|
||||
_cachedPolygons = polygons;
|
||||
return _cachedMorphs = [
|
||||
for (var i = 0; i < polygons.length; i++)
|
||||
Morph(polygons[i], polygons[(i + 1) % polygons.length]),
|
||||
];
|
||||
}
|
||||
|
||||
double _resolveDimension(
|
||||
BoxConstraints parentConstraints,
|
||||
BoxConstraints preferredConstraints,
|
||||
@@ -293,87 +259,16 @@ class _CommonCircleLoadingState extends State<CommonCircleLoading>
|
||||
}
|
||||
}
|
||||
|
||||
class _ShapeSpec {
|
||||
final double points;
|
||||
final double innerRadiusRatio;
|
||||
final double pointRounding;
|
||||
final double valleyRounding;
|
||||
final double widthScale;
|
||||
final double heightScale;
|
||||
final double squash;
|
||||
|
||||
const _ShapeSpec({
|
||||
required this.points,
|
||||
required this.innerRadiusRatio,
|
||||
required this.pointRounding,
|
||||
this.valleyRounding = 0,
|
||||
this.widthScale = 1,
|
||||
this.heightScale = 1,
|
||||
this.squash = 0,
|
||||
});
|
||||
|
||||
static _ShapeSpec lerp(_ShapeSpec begin, _ShapeSpec end, double progress) {
|
||||
return _ShapeSpec(
|
||||
points: _lerp(begin.points, end.points, progress),
|
||||
innerRadiusRatio: _lerp(
|
||||
begin.innerRadiusRatio,
|
||||
end.innerRadiusRatio,
|
||||
progress,
|
||||
),
|
||||
pointRounding: _lerp(begin.pointRounding, end.pointRounding, progress),
|
||||
valleyRounding: _lerp(begin.valleyRounding, end.valleyRounding, progress),
|
||||
widthScale: _lerp(begin.widthScale, end.widthScale, progress),
|
||||
heightScale: _lerp(begin.heightScale, end.heightScale, progress),
|
||||
squash: _lerp(begin.squash, end.squash, progress),
|
||||
);
|
||||
}
|
||||
|
||||
static _ShapeSpec fromStarBorder(StarBorder border) {
|
||||
return _ShapeSpec(
|
||||
points: border.points,
|
||||
innerRadiusRatio: border.innerRadiusRatio,
|
||||
pointRounding: border.pointRounding,
|
||||
valleyRounding: border.valleyRounding,
|
||||
squash: border.squash,
|
||||
);
|
||||
}
|
||||
|
||||
static double _lerp(double begin, double end, double progress) {
|
||||
return begin + (end - begin) * progress;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _ShapeSpec &&
|
||||
other.points == points &&
|
||||
other.innerRadiusRatio == innerRadiusRatio &&
|
||||
other.pointRounding == pointRounding &&
|
||||
other.valleyRounding == valleyRounding &&
|
||||
other.widthScale == widthScale &&
|
||||
other.heightScale == heightScale &&
|
||||
other.squash == squash;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
points,
|
||||
innerRadiusRatio,
|
||||
pointRounding,
|
||||
valleyRounding,
|
||||
widthScale,
|
||||
heightScale,
|
||||
squash,
|
||||
);
|
||||
}
|
||||
|
||||
class _ShapePainter extends CustomPainter {
|
||||
final _ShapeSpec shape;
|
||||
class _MorphPainter extends CustomPainter {
|
||||
final Morph morph;
|
||||
final double progress;
|
||||
final Color color;
|
||||
final double scaleFactor;
|
||||
final Paint _paint;
|
||||
|
||||
_ShapePainter({
|
||||
required this.shape,
|
||||
_MorphPainter({
|
||||
required this.morph,
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.scaleFactor,
|
||||
}) : _paint = Paint()
|
||||
@@ -383,24 +278,19 @@ class _ShapePainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Rect.fromCenter(
|
||||
center: size.center(Offset.zero),
|
||||
width: size.width * scaleFactor * shape.widthScale,
|
||||
height: size.height * scaleFactor * shape.heightScale,
|
||||
);
|
||||
final border = StarBorder(
|
||||
points: shape.points,
|
||||
innerRadiusRatio: shape.innerRadiusRatio,
|
||||
pointRounding: shape.pointRounding,
|
||||
valleyRounding: shape.valleyRounding,
|
||||
squash: shape.squash,
|
||||
);
|
||||
canvas.drawPath(border.getOuterPath(rect), _paint);
|
||||
final scale = size.width * scaleFactor;
|
||||
final offset = (size.width - scale) / 2;
|
||||
canvas.save();
|
||||
canvas.translate(offset, offset);
|
||||
canvas.scale(scale);
|
||||
canvas.drawPath(morph.toPath(progress: progress), _paint);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ShapePainter oldDelegate) {
|
||||
return oldDelegate.shape != shape ||
|
||||
bool shouldRepaint(covariant _MorphPainter oldDelegate) {
|
||||
return oldDelegate.morph != morph ||
|
||||
oldDelegate.progress != progress ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.scaleFactor != scaleFactor;
|
||||
}
|
||||
|
||||
@@ -926,6 +926,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
material_new_shapes:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: material_new_shapes
|
||||
sha256: e4bc375205e187e8fb232573387112dd8c0dd45b03af8aa2b3c79eb4b9e3e0dc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
material_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -59,6 +59,7 @@ dependencies:
|
||||
riverpod_annotation: ^4.0.3
|
||||
riverpod: ^3.3.2
|
||||
material_color_utilities: ^0.13.0
|
||||
material_new_shapes: ^1.0.0
|
||||
flutter_js: ^0.8.7
|
||||
flutter_svg: ^2.1.0
|
||||
flutter_cache_manager: ^3.4.1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:fl_clash/common/shape.dart';
|
||||
import 'package:fl_clash/widgets/loading.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -124,7 +125,10 @@ void main() {
|
||||
variant: LoadingIndicatorM3EVariant.contained,
|
||||
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
|
||||
padding: const EdgeInsets.all(4),
|
||||
polygons: const [StarBorder(points: 6), StarBorder(points: 8)],
|
||||
polygons: [
|
||||
RoundedPolygon.star(numVerticesPerRadius: 6),
|
||||
RoundedPolygon.star(numVerticesPerRadius: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user