Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
1fccb31
Copy ksokolovskyi/material_shapes into material_ui.
ksokolovskyi Aug 21, 2026
6c460a7
Apply dart format and fix.
ksokolovskyi Aug 22, 2026
e17a104
Add license headers.
ksokolovskyi Aug 22, 2026
33011e8
Collapse the doubled shapes directory.
ksokolovskyi Aug 22, 2026
3e688cf
Split the shapes part library into separate libraries.
ksokolovskyi Aug 22, 2026
729715b
Rename Cubic to CubicBezier.
ksokolovskyi Aug 22, 2026
07a87d1
Align shapes visibility with the AndroidX public API.
ksokolovskyi Aug 23, 2026
36c8770
Document the public members of the shapes API.
ksokolovskyi Aug 23, 2026
0e1f0fa
Fix analyzer issues.
ksokolovskyi Aug 23, 2026
dcb65e9
Expose material shapes from material_ui.
ksokolovskyi Aug 23, 2026
9bcdadf
Add material shapes example.
ksokolovskyi Aug 23, 2026
92a3707
Add golden tests for all MaterialShapes.
ksokolovskyi Aug 23, 2026
5907f49
Make the internal Point an alias for Offset.
ksokolovskyi Aug 30, 2026
eb8195c
Return Rect from the shapes bounds methods.
ksokolovskyi Aug 30, 2026
ff1cae7
Use an Offset center in the RoundedPolygon API.
ksokolovskyi Aug 30, 2026
2492e0b
Use a List<Offset> for the RoundedPolygon vertices.
ksokolovskyi Aug 30, 2026
25304df
Use Offset in the CubicBezier API.
ksokolovskyi Aug 31, 2026
5147b00
Clean up the shapes toPath parameters and fix the rotation pivot.
ksokolovskyi Aug 31, 2026
b0d2c7a
Fix the shapes equality and hashCode list-identity bugs.
ksokolovskyi Aug 31, 2026
0bd1924
Add value equality to the shapes features.
ksokolovskyi Aug 31, 2026
b0d14b2
Add more MaterialShapeBorder tests.
ksokolovskyi Aug 31, 2026
70be604
Cache the MaterialShapeBorder morph across lerps.
ksokolovskyi Aug 31, 2026
214221d
Fix the MaterialShapeBorder crash on an interrupted transition.
ksokolovskyi Aug 31, 2026
ee72777
Rename CubicBezier.zeroLength() to isZeroLength.
ksokolovskyi Aug 31, 2026
fd2e02b
Rename Morph.asCubics() to toCubics().
ksokolovskyi Aug 31, 2026
6009439
Make RoundedPolygon.fromVerticesNum the default constructor.
ksokolovskyi Aug 31, 2026
fb5d3d8
Rename the Feature factories off their Kotlin build* prefix.
ksokolovskyi Aug 31, 2026
a9eacd7
Unify the shapes reverse naming on a reversed getter.
ksokolovskyi Aug 31, 2026
64fc89f
Turn the shapes bounds methods into getters.
ksokolovskyi Aug 31, 2026
f4f02a8
Turn RoundedPolygon.normalized into a getter.
ksokolovskyi Aug 31, 2026
628df26
Name the shapes transformed() parameter transformer.
ksokolovskyi Aug 31, 2026
06fd546
Delete the RoundedPolygon copy constructor.
ksokolovskyi Aug 31, 2026
0a2d2fb
Rename CubicBezier.pointOnCurve() to pointAt().
ksokolovskyi Aug 31, 2026
99e3732
Rename CubicBezier.empty() to CubicBezier.point().
ksokolovskyi Aug 31, 2026
239ed78
Add value equality to CornerRounding.
ksokolovskyi Sep 4, 2026
f593808
Add value equality and public shapes to Morph.
ksokolovskyi Sep 4, 2026
bb43b01
Make RoundedPolygon.cubics and .features unmodifiable.
ksokolovskyi Sep 5, 2026
866d7b7
Make the Feature generative constructor private.
ksokolovskyi Sep 5, 2026
ebf0f77
Name the type in the shapes toString implementations.
ksokolovskyi Sep 5, 2026
09e3573
Update CornerRounding assert message.
ksokolovskyi Sep 5, 2026
8adf348
Check RoundedPolygon contiguity outside an assert.
ksokolovskyi Sep 5, 2026
288f313
Use Offset's own geometry in PointGeometry.
ksokolovskyi Sep 5, 2026
db5ea6f
Get rid of x/y-argument geometry twins.
ksokolovskyi Sep 5, 2026
d837c0a
Rename PointGeometry.clockwise to turnsClockwiseTo.
ksokolovskyi Sep 5, 2026
1a01e46
Type cutAdjusts as (double, double) rather than (num, num).
ksokolovskyi Sep 5, 2026
28ddebf
Use listEquals in the CubicBezier and RoundedPolygon equality.
ksokolovskyi Sep 5, 2026
b88ec77
Rename float_mapping.dart to double_mapping.dart.
ksokolovskyi Sep 5, 2026
edd17b0
Delete the MeasuredFeatures typedef.
ksokolovskyi Sep 5, 2026
2dc43fe
Rename the MeasuredPolygon measure and cubic accessors.
ksokolovskyi Sep 5, 2026
f6edb8e
Name the Measurer parameters cubic and measure.
ksokolovskyi Sep 5, 2026
6c16dce
Update docs and comments.
ksokolovskyi Sep 6, 2026
95fdb70
Add missing const.
ksokolovskyi Sep 6, 2026
916f784
Merge branch 'main' into add-material-shapes-to-material-ui
ksokolovskyi Sep 6, 2026
2e3f482
Update pending changelog.
ksokolovskyi Sep 6, 2026
be59266
Fix typo in innerRadiusRatio error message.
ksokolovskyi Sep 6, 2026
1d11158
Remove List.generate calls in CubicBezier + and * operators.
ksokolovskyi Sep 6, 2026
dc776d0
Remove List.generate call in Morph.toCubics function.
ksokolovskyi Sep 6, 2026
3ca9ee6
Guard LengthMeasurer._closestProgressTo against dividing by zero.
ksokolovskyi Sep 6, 2026
01b88fc
Guard RoundedPolygon.normalized against dividing by zero for a point-…
ksokolovskyi Sep 6, 2026
df808f7
Format doubles in CubicBezier.toString and CornerRounding.toString wi…
ksokolovskyi Sep 7, 2026
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
7 changes: 7 additions & 0 deletions packages/material_ui/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'progress_indicator/linear_progress_indicator.0.dart'
import 'progress_indicator/circular_progress_indicator.0.dart'
as circular_progress_indicator_0;
import 'dropdown_menu/dropdown_menu.1.dart' as dropdown_menu_1;
import 'material_shapes/material_shapes.0.dart' as material_shapes_0;
import 'navigation_bar/navigation_bar.1.dart' as navigation_bar_1;
import 'navigation_rail/navigation_rail.0.dart' as navigation_rail_0;
import 'navigation_drawer/navigation_drawer.0.dart' as navigation_drawer_0;
Expand Down Expand Up @@ -161,6 +162,12 @@ class ExampleApp extends StatelessWidget {
builder: (BuildContext context) =>
const circular_progress_indicator_0.ProgressIndicatorExampleApp(),
),
_Example(
filepath: 'material_shapes/material_shapes.0.dart',
title: 'Material shapes',
builder: (BuildContext context) =>
const material_shapes_0.MaterialShapesExampleApp(),
),
_Example(
filepath: 'dropdown_menu/dropdown_menu.1.dart',
title: 'Menu',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// #region body
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:material_ui/material_ui.dart';

/// Flutter code sample for [MaterialShapes].

void main() {
runApp(const MaterialShapesExampleApp());
}

class MaterialShapesExampleApp extends StatelessWidget {
const MaterialShapesExampleApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(colorSchemeSeed: const Color(0xFF6750A4)),
home: const MaterialShapesExample(),
);
}
}

class MaterialShapesExample extends StatefulWidget {
const MaterialShapesExample({super.key});

@override
State<MaterialShapesExample> createState() => _MaterialShapesExampleState();
}

class _MaterialShapesExampleState extends State<MaterialShapesExample>
with SingleTickerProviderStateMixin {
/// The shapes to morph through, in the order they are listed in
/// [MaterialShapes.all].
static final List<RoundedPolygon> _shapes = MaterialShapes.all;

/// A [Morph] from each shape in [_shapes] to the next one, wrapping around
/// from the last shape back to the first.
///
/// These are built up front because a [Morph] computes the mapping between
/// its two shapes when it is constructed.
late final List<Morph> _morphs;

/// The morph that is currently animating.
late final ValueNotifier<Morph> _morph;

/// Drives the progress of [_morph].
///
/// The controller is unbounded so that the springs below can overshoot past a
/// progress of 1, which carries the morph slightly beyond its end shape.
late final AnimationController _controller;

int _morphIndex = 0;

Timer? _startTimer;

final _bouncySimulation = SpringSimulation(
SpringDescription.withDampingRatio(ratio: 0.5, stiffness: 400, mass: 1),
0,
1,
5,
snapToEnd: true,
);

// Overshooting past a progress of 1 extrapolates a morph beyond its end
// shape, which distorts the heart into sharp spikes. The heart therefore
// animates with a spring that starts at rest and barely overshoots.
final _lessBouncySimulation = SpringSimulation(
SpringDescription.withDampingRatio(ratio: 0.8, stiffness: 300, mass: 1),
0,
1,
0,
snapToEnd: true,
);

/// The shape that the currently animating morph ends on.
RoundedPolygon get _targetShape =>
_shapes[(_morphIndex + 1) % _shapes.length];

@override
void initState() {
super.initState();

_morphs = <Morph>[
for (var i = 0; i < _shapes.length; i++)
Morph(_shapes[i], _shapes[(i + 1) % _shapes.length]),
];
_morph = ValueNotifier<Morph>(_morphs.first);
_controller = AnimationController.unbounded(vsync: this);

// Hold the first shape for a moment so that it can be seen before it starts
// morphing.
_startTimer = Timer(const Duration(seconds: 1), _morphThroughShapes);
}

@override
void dispose() {
_startTimer?.cancel();
_controller.dispose();
_morph.dispose();
super.dispose();
}

/// Animates the morphs in [_morphs] one after another, looping back to the
/// first one after the last, until this widget is disposed.
///
/// Waiting for each spring to settle, rather than advancing on a fixed
/// interval, keeps a morph from being cut short and popping to the next
/// shape.
Future<void> _morphThroughShapes() async {
while (mounted) {
_morph.value = _morphs[_morphIndex];
_controller.value = 0;
await _controller.animateWith(
_targetShape == MaterialShapes.heart
? _lessBouncySimulation
: _bouncySimulation,
);
_morphIndex = (_morphIndex + 1) % _morphs.length;
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Material Shapes Sample')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300),
child: AspectRatio(
aspectRatio: 1,
child: RepaintBoundary(
child: CustomPaint(
painter: _MorphPainter(
morph: _morph,
progress: _controller,
color: Theme.of(context).colorScheme.primary,
),
willChange: true,
child: const SizedBox.expand(),
),
),
),
),
),
);
}
}

class _MorphPainter extends CustomPainter {
_MorphPainter({
required this.morph,
required this.progress,
required this.color,
}) : super(repaint: Listenable.merge(<Listenable>[morph, progress]));

final ValueListenable<Morph> morph;

final Animation<double> progress;

final Color color;

late final Paint _paint = Paint()
..style = .fill
..color = color;
Comment thread
ksokolovskyi marked this conversation as resolved.

@override
void paint(Canvas canvas, Size size) {
// Every shape in MaterialShapes is normalized, so its path fits in a one by
// one square that has to be scaled up to the size being painted.
canvas
..save()
..scale(size.width)
..drawPath(morph.value.toPath(progress.value), _paint)
..restore();
}

@override
bool shouldRepaint(_MorphPainter oldDelegate) {
return oldDelegate.morph != morph ||
oldDelegate.progress != progress ||
oldDelegate.color != color;
}
}
// #endregion body
3 changes: 3 additions & 0 deletions packages/material_ui/example/test/main_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import 'package:material_ui_examples/progress_indicator/circular_progress_indica
as circular_progress_indicator_0;
import 'package:material_ui_examples/dropdown_menu/dropdown_menu.1.dart'
as dropdown_menu_1;
import 'package:material_ui_examples/material_shapes/material_shapes.0.dart'
as material_shapes_0;
import 'package:material_ui_examples/navigation_bar/navigation_bar.1.dart'
as navigation_bar_1;
import 'package:material_ui_examples/navigation_rail/navigation_rail.0.dart'
Expand Down Expand Up @@ -87,6 +89,7 @@ const Map<String, Type> _examples = <String, Type>{
linear_progress_indicator_0.ProgressIndicatorExampleApp,
'Circular progress indicators':
circular_progress_indicator_0.ProgressIndicatorExampleApp,
'Material shapes': material_shapes_0.MaterialShapesExampleApp,
'Menu': dropdown_menu_1.DropdownMenuApp,
'Navigation bar': navigation_bar_1.NavigationBarApp,
'Navigation rail': navigation_rail_0.NavigationRailExampleApp,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:material_ui_examples/material_shapes/material_shapes.0.dart'
as example;

void main() {
/// Comfortably longer than it takes either of the example's springs to
/// settle.
const Duration morphDuration = Duration(seconds: 3);

/// Samples points on a grid over the unit square that are clearly [inside]
/// (or outside) of [path].
List<Offset> samplePoints(Path path, {required bool inside}) {
const step = 0.09;
const margin = 0.02;

final List<Offset> points = <Offset>[];
for (var x = step; x < 1; x += step) {
for (var y = step; y < 1; y += step) {
final bool clearlyInOrOut = <Offset>[
Offset(x, y),
Offset(x - margin, y),
Offset(x + margin, y),
Offset(x, y - margin),
Offset(x, y + margin),
].every((Offset point) => path.contains(point) == inside);
if (clearlyInOrOut) {
points.add(Offset(x, y));
}
}
}
return points;
}

testWidgets('Material shapes morph through every shape and wrap around', (
WidgetTester tester,
) async {
await tester.pumpWidget(const example.MaterialShapesExampleApp());

expect(find.text('Material Shapes Sample'), findsOne);

final Finder shape = find.byWidgetPredicate(
(Widget widget) => widget is CustomPaint && widget.willChange,
);
expect(tester.getSize(shape), const Size.square(300));

final ThemeData theme = Theme.of(
tester.element(find.byType(example.MaterialShapesExample)),
);

// The circle is painted first, scaled up to fill the paint area, and is
// held still before the morphing starts.
expect(
shape,
paints
..scale(x: 300)
..path(
color: theme.colorScheme.primary,
style: PaintingStyle.fill,
includes: const <Offset>[Offset(0.5, 0.5)],
excludes: const <Offset>[Offset(0.1, 0.1)],
),
);
expect(tester.hasRunningAnimations, isFalse);

// The morphing starts after holding the first shape for a second.
await tester.pump(const Duration(seconds: 1));

// Morph through the whole list of shapes, wrapping around from the last
// shape back to the first one.
final shapeCount = MaterialShapes.all.length;
for (var i = 0; i < shapeCount; i++) {
expect(tester.hasRunningAnimations, isTrue);
await tester.pump(morphDuration);

final RoundedPolygon target = MaterialShapes.all[(i + 1) % shapeCount];
final Path targetPath = target.toPath();
expect(
shape,
paints..path(
color: theme.colorScheme.primary,
style: PaintingStyle.fill,
includes: samplePoints(targetPath, inside: true),
excludes: samplePoints(targetPath, inside: false),
),
reason:
'Morph $i should have settled on shape ${(i + 1) % shapeCount}.',
);
}

// Disposing the example stops the animation.
await tester.pumpWidget(const SizedBox());
expect(tester.hasRunningAnimations, isFalse);
});
}
3 changes: 3 additions & 0 deletions packages/material_ui/lib/material_ui.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export 'src/magnifier.dart';
export 'src/material.dart';
export 'src/material_button.dart';
export 'src/material_localizations.dart';
export 'src/material_shape_border.dart';
export 'src/material_shapes.dart';
export 'src/material_state.dart';
export 'src/material_state_mixin.dart';
export 'src/menu_anchor.dart';
Expand Down Expand Up @@ -166,6 +168,7 @@ export 'src/segmented_button_theme.dart';
export 'src/selectable_text.dart';
export 'src/selection_area.dart';
export 'src/shadows.dart';
export 'src/shapes/shapes.dart';
export 'src/slider.dart';
export 'src/slider_parts.dart';
export 'src/slider_theme.dart';
Expand Down
Loading
Loading