forked from mirrors/material_3_expressive
Add initial configuration, tokens, and widgets for M3E components
- Introduced `.gitignore` and `.metadata` for apps and examples. - Added Flutter/Dart analysis configurations (`analysis_options.yaml`). - Implemented foundational tokens and themes for M3E (colors, shapes). - Created base implementations for `IconButtonM3E` and `SplitButtonM3E`. - Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
parent
2c0f2df0b8
commit
62ecb86b76
184 changed files with 9872 additions and 0 deletions
|
|
@ -0,0 +1,6 @@
|
|||
library loading_indicator_m3e;
|
||||
|
||||
export 'src/enums.dart';
|
||||
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
|
||||
export 'src/expressive_loading_indicator.dart';
|
||||
export 'src/loading_indicator_m3e.dart';
|
||||
1
packages/loading_indicator_m3e/lib/src/enums.dart
Normal file
1
packages/loading_indicator_m3e/lib/src/enums.dart
Normal file
|
|
@ -0,0 +1 @@
|
|||
enum LoadingIndicatorM3EVariant { defaultStyle, contained }
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
// Port of Android's LoadingIndicator
|
||||
// Source: androidx/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt
|
||||
// Copyright (c) 2024 The Android Open Source Project
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
|
||||
/// A Material Design loading indicator.
|
||||
///
|
||||
/// This version of the loading indicator morphs between its [polygons] shapes.
|
||||
/// 
|
||||
class ExpressiveLoadingIndicator extends ProgressIndicator {
|
||||
/// A list of [RoundedPolygon]s for the sequence of shapes this loading indicator
|
||||
/// will morph between. The loading indicator expects at least two items in that list.
|
||||
final List<RoundedPolygon>? polygons;
|
||||
|
||||
/// Defines minimum and maximum sizes for an [ExpressiveLoadingIndicator].
|
||||
/// If null, then the [ProgressIndicatorThemeData.constraints] will be used. Otherwise, defaults to a minimum width and height of 48 pixels.
|
||||
final BoxConstraints? constraints;
|
||||
|
||||
const ExpressiveLoadingIndicator({
|
||||
super.key,
|
||||
super.color,
|
||||
this.polygons,
|
||||
this.constraints,
|
||||
super.semanticsLabel,
|
||||
super.semanticsValue,
|
||||
}) : assert(polygons != null ? polygons.length > 1 : true);
|
||||
|
||||
@override
|
||||
State<ExpressiveLoadingIndicator> createState() =>
|
||||
_ExpressiveLoadingIndicatorState();
|
||||
}
|
||||
|
||||
class _ExpressiveLoadingIndicatorState extends State<ExpressiveLoadingIndicator>
|
||||
with TickerProviderStateMixin {
|
||||
static final List<RoundedPolygon> _defaultPolygons = [
|
||||
MaterialShapes.softBurst,
|
||||
MaterialShapes.cookie9Sided,
|
||||
MaterialShapes.pentagon,
|
||||
MaterialShapes.pill,
|
||||
MaterialShapes.sunny,
|
||||
MaterialShapes.cookie4Sided,
|
||||
MaterialShapes.oval,
|
||||
];
|
||||
|
||||
static final BoxConstraints _defaultConstraints = BoxConstraints(
|
||||
minWidth: 48.0,
|
||||
minHeight: 48.0,
|
||||
maxWidth: 48.0,
|
||||
maxHeight: 48.0,
|
||||
); // default from kotlin source
|
||||
|
||||
late final List<RoundedPolygon> _polygons;
|
||||
|
||||
static const int _globalRotationDurationMs = 4666;
|
||||
static const int _morphIntervalMs = 650;
|
||||
static const double _fullRotation = 360.0;
|
||||
|
||||
static const double _quarterRotation = _fullRotation / 4;
|
||||
static const double _activeSize = 38; // based on source spec
|
||||
|
||||
late final List<Morph> _morphSequence;
|
||||
|
||||
late final AnimationController _morphController;
|
||||
late final AnimationController _globalRotationController;
|
||||
int _currentMorphIndex = 0;
|
||||
double _morphRotationTargetAngle = _quarterRotation;
|
||||
|
||||
Timer? _morphTimer;
|
||||
|
||||
final _morphAnimationSpec = SpringSimulation(
|
||||
SpringDescription.withDampingRatio(ratio: 0.6, stiffness: 200.0, mass: 1.0),
|
||||
0.0,
|
||||
1.0,
|
||||
5.0,
|
||||
snapToEnd: true,
|
||||
);
|
||||
|
||||
late BoxConstraints _constraints;
|
||||
late Color _color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final indicatorTheme = ProgressIndicatorTheme.of(context);
|
||||
_color =
|
||||
widget.color ??
|
||||
indicatorTheme.color ??
|
||||
Theme.of(context).colorScheme.primary;
|
||||
_constraints =
|
||||
widget.constraints ?? indicatorTheme.constraints ?? _defaultConstraints;
|
||||
|
||||
final activeIndicatorScale =
|
||||
_activeSize / math.min(_constraints.maxWidth, _constraints.maxHeight);
|
||||
|
||||
final shapesScaleFactor =
|
||||
_calculateScaleFactor(_polygons) * activeIndicatorScale;
|
||||
|
||||
return Semantics.fromProperties(
|
||||
properties: SemanticsProperties(
|
||||
label: widget.semanticsLabel,
|
||||
value: widget.semanticsValue,
|
||||
),
|
||||
child: RepaintBoundary(
|
||||
child: ConstrainedBox(
|
||||
constraints: _constraints,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_morphController,
|
||||
_globalRotationController,
|
||||
]),
|
||||
builder: (context, child) {
|
||||
final morphProgress = _morphController.value.clamp(0.0, 1.0);
|
||||
final globalRotationDegrees =
|
||||
_globalRotationController.value * _fullRotation;
|
||||
|
||||
// calculate total rotation (clockwise, matching Kotlin implementation)
|
||||
final totalRotationDegrees =
|
||||
morphProgress * _quarterRotation +
|
||||
_morphRotationTargetAngle +
|
||||
globalRotationDegrees;
|
||||
|
||||
final totalRotationRadians =
|
||||
totalRotationDegrees * (math.pi / 180.0);
|
||||
|
||||
return Transform.rotate(
|
||||
angle: totalRotationRadians,
|
||||
child: CustomPaint(
|
||||
painter: _MorphPainter(
|
||||
morph: _morphSequence[_currentMorphIndex],
|
||||
progress: morphProgress,
|
||||
color: _color,
|
||||
scaleFactor: shapesScaleFactor,
|
||||
repaint: Listenable.merge([
|
||||
_morphController,
|
||||
_globalRotationController,
|
||||
]),
|
||||
),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_morphTimer?.cancel();
|
||||
_morphController.dispose();
|
||||
_globalRotationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_polygons = widget.polygons ?? _defaultPolygons;
|
||||
|
||||
_morphSequence = _createMorphSequence(_polygons, circularSequence: true);
|
||||
|
||||
_morphController = AnimationController.unbounded(vsync: this);
|
||||
|
||||
// continuous linear rotation
|
||||
_globalRotationController = AnimationController(
|
||||
duration: const Duration(milliseconds: _globalRotationDurationMs),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_startAnimations();
|
||||
}
|
||||
|
||||
List<Morph> _createMorphSequence(
|
||||
List<RoundedPolygon> polygons, {
|
||||
required bool circularSequence,
|
||||
}) {
|
||||
final morphs = <Morph>[];
|
||||
|
||||
for (int i = 0; i < polygons.length; i++) {
|
||||
if (i + 1 < polygons.length) {
|
||||
morphs.add(Morph(polygons[i], polygons[i + 1]));
|
||||
} else if (circularSequence) {
|
||||
// morph from last shape back to first shape
|
||||
morphs.add(Morph(polygons[i], polygons[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return morphs;
|
||||
}
|
||||
|
||||
/// Calculates a scale factor that will be used when scaling the provided [RoundedPolygon]s into a
|
||||
/// specified sized container.
|
||||
///
|
||||
/// Since the polygons may rotate, a simple [RoundedPolygon.calculateBounds] is not enough to
|
||||
/// determine the size the polygon will occupy as it rotates. Using the simple bounds calculation may
|
||||
/// result in a clipped shape.
|
||||
///
|
||||
/// This function calculates and returns a scale factor by utilizing the
|
||||
/// [RoundedPolygon.calculateMaxBounds] and comparing its result to the
|
||||
/// [RoundedPolygon.calculateBounds]. The scale factor can later be used when calling [processPath].
|
||||
///
|
||||
/// Port of Kotlin implementation.
|
||||
double _calculateScaleFactor(List<RoundedPolygon> polygons) {
|
||||
var scaleFactor = 1.0;
|
||||
|
||||
for (final polygon in polygons) {
|
||||
final bounds = polygon.calculateBounds();
|
||||
final maxBounds = polygon.calculateMaxBounds();
|
||||
|
||||
final boundsWidth = bounds[2] - bounds[0];
|
||||
final boundsHeight = bounds[3] - bounds[1];
|
||||
|
||||
final maxBoundsWidth = maxBounds[2] - maxBounds[0];
|
||||
final maxBoundsHeight = maxBounds[3] - maxBounds[1];
|
||||
|
||||
final scaleX = boundsWidth / maxBoundsWidth;
|
||||
final scaleY = boundsHeight / maxBoundsHeight;
|
||||
|
||||
// We use max(scaleX, scaleY) to handle cases like a pill-shape that can throw off the
|
||||
// entire calculation.
|
||||
scaleFactor = math.min(scaleFactor, math.max(scaleX, scaleY));
|
||||
}
|
||||
|
||||
return scaleFactor;
|
||||
}
|
||||
|
||||
void _startAnimations() {
|
||||
// infinite global rotation
|
||||
_globalRotationController.repeat();
|
||||
|
||||
// periodic morph cycle
|
||||
_morphTimer = Timer.periodic(
|
||||
const Duration(milliseconds: _morphIntervalMs),
|
||||
(_) => _startMorphCycle(),
|
||||
);
|
||||
|
||||
_startMorphCycle();
|
||||
}
|
||||
|
||||
void _startMorphCycle() {
|
||||
if (!mounted) return;
|
||||
|
||||
// move to next morph in sequence
|
||||
_currentMorphIndex = (_currentMorphIndex + 1) % _morphSequence.length;
|
||||
|
||||
// accumulate rotation target
|
||||
_morphRotationTargetAngle =
|
||||
(_morphRotationTargetAngle + _quarterRotation) % _fullRotation;
|
||||
|
||||
// Reset and start morph animation
|
||||
_morphController
|
||||
..value = 0.0
|
||||
..animateWith(_morphAnimationSpec);
|
||||
}
|
||||
}
|
||||
|
||||
class _MorphPainter extends CustomPainter {
|
||||
final Morph morph;
|
||||
final double progress;
|
||||
final Color color;
|
||||
|
||||
/// A scale factor that will be taken into account uniformly when the [path] is
|
||||
/// scaled (i.e. the scaleX would be the [size] width x the scale factor, and the scaleY would be
|
||||
/// the [size] height x the scale factor)
|
||||
final double scaleFactor;
|
||||
|
||||
_MorphPainter({
|
||||
required this.morph,
|
||||
required this.progress,
|
||||
required this.color,
|
||||
this.scaleFactor = 1.0,
|
||||
super.repaint,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final path = morph.toPath(progress: progress);
|
||||
final processedPath = _processPath(path, size);
|
||||
canvas.drawPath(
|
||||
processedPath,
|
||||
Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_MorphPainter oldDelegate) {
|
||||
return oldDelegate.morph != morph ||
|
||||
oldDelegate.progress != progress ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.scaleFactor != scaleFactor;
|
||||
}
|
||||
|
||||
/// Process a given path to scale it and center it inside the given size.
|
||||
///
|
||||
/// [path] takes a [Path] that was generated by a _normalized_ [Morph] or [RoundedPolygon].
|
||||
/// [size] takes a [Size] that the provided [path] is going to be scaled and centered into.
|
||||
Path _processPath(Path path, Size size) {
|
||||
// a [Matrix] that would be used to apply the scaling. Note that any provided
|
||||
// matrix will be reset in this function.
|
||||
final Matrix4 scaleMatrix = Matrix4.diagonal3Values(
|
||||
size.width * scaleFactor,
|
||||
size.height * scaleFactor,
|
||||
1,
|
||||
);
|
||||
final Path scaledPath = path.transform(scaleMatrix.storage);
|
||||
|
||||
// Translate the path so that its center aligns with the center of the container.
|
||||
final Rect bounds = scaledPath.getBounds();
|
||||
final Offset translation =
|
||||
Offset(size.width / 2, size.height / 2) - bounds.center;
|
||||
final Path finalPath = scaledPath.shift(translation);
|
||||
|
||||
return finalPath;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:material_new_shapes/material_new_shapes.dart';
|
||||
import 'expressive_loading_indicator.dart';
|
||||
import 'loading_tokens_adapter.dart';
|
||||
import 'enums.dart';
|
||||
|
||||
/// Material 3 Expressive Loading Indicator
|
||||
/// - Default: floating morphing shape on surface
|
||||
/// - Contained: icon inside colored container (primary container) using onPrimaryContainer
|
||||
class LoadingIndicatorM3E extends StatelessWidget {
|
||||
const LoadingIndicatorM3E({
|
||||
super.key,
|
||||
this.variant = LoadingIndicatorM3EVariant.defaultStyle,
|
||||
this.color,
|
||||
this.containerColor,
|
||||
this.polygons,
|
||||
this.constraints,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
this.semanticValue,
|
||||
});
|
||||
|
||||
final LoadingIndicatorM3EVariant variant;
|
||||
final Color? color;
|
||||
final Color? containerColor;
|
||||
final List<RoundedPolygon>? polygons;
|
||||
final BoxConstraints? constraints;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final String? semanticLabel;
|
||||
final String? semanticValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = LoadingTokensAdapter(context);
|
||||
final size = Size(tokens.containerWidth(), tokens.containerHeight());
|
||||
|
||||
final cons = constraints ?? BoxConstraints.tight(size);
|
||||
|
||||
final activeColor = switch (variant) {
|
||||
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
|
||||
LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(),
|
||||
};
|
||||
|
||||
final containerBg = switch (variant) {
|
||||
LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(),
|
||||
LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(),
|
||||
};
|
||||
|
||||
final indicator = ExpressiveLoadingIndicator(
|
||||
color: activeColor,
|
||||
polygons: polygons,
|
||||
semanticsLabel: semanticLabel,
|
||||
semanticsValue: semanticValue,
|
||||
constraints: cons,
|
||||
);
|
||||
|
||||
if (variant == LoadingIndicatorM3EVariant.defaultStyle) {
|
||||
// Default: subtle container (secondaryContainer)
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: containerBg,
|
||||
borderRadius: tokens.containerRadius(),
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(0),
|
||||
child: indicator,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Contained: stronger container (primaryContainer) and contrasting active indicator
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: containerBg,
|
||||
borderRadius: tokens.containerRadius(),
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(0),
|
||||
child: indicator,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:m3e_design/m3e_design.dart';
|
||||
|
||||
@immutable
|
||||
class LoadingTokensAdapter {
|
||||
const LoadingTokensAdapter(this.context);
|
||||
final BuildContext context;
|
||||
|
||||
M3ETheme get _m3e {
|
||||
final t = Theme.of(context);
|
||||
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
|
||||
}
|
||||
|
||||
// Active indicator color (Default variant)
|
||||
Color activeColor() => _m3e.colors.primary;
|
||||
|
||||
// Container color (Default variant -> transparent background)
|
||||
Color containerColorDefault() => Colors.transparent;
|
||||
|
||||
// Contained variant colors
|
||||
Color containedContainerColor() => _m3e.colors.primaryContainer;
|
||||
Color containedActiveColor() => _m3e.colors.onPrimaryContainer;
|
||||
|
||||
// Size tokens (from spec)
|
||||
double containerWidth() => 48; // container height/width
|
||||
double containerHeight() => 48;
|
||||
double activeIndicatorSize() => 38;
|
||||
|
||||
// Shape: full corners
|
||||
BorderRadius containerRadius() => BorderRadius.circular(999);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue