diff --git a/.github/workflows/flutter_analysis.yml b/.github/workflows/flutter_analysis.yml index e0ef9e257..e2b5a2b40 100644 --- a/.github/workflows/flutter_analysis.yml +++ b/.github/workflows/flutter_analysis.yml @@ -34,4 +34,20 @@ jobs: run: flutter pub publish --dry-run - name: Run tests - run: flutter test + run: flutter test --exclude-tags=golden + + acrylic-goldens: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v2 + - name: Install Flutter + uses: subosito/flutter-action@main + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Run Acrylic golden tests + run: flutter test --tags=golden diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3dba0de..2ca0b54ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [next] +- refactor: Align `Acrylic` with WinUI behavior, including theme-driven tint and luminosity resources, corrected fallback rendering, and improved material composition ([#1362](https://github.com/bdlukaa/fluent_ui/pull/1362)) - feat: add logical start/end overflow direction support to `DynamicOverflow` ([#1359](https://github.com/bdlukaa/fluent_ui/pull/1359)) - refactor: `CommandBar` now uses an anchored, CommandBar-owned overflow popup with stable layout, end alignment, outside-tap dismissal, keyboard/touch-friendly interaction, and WinUI-inspired presenter styling ([#1359](https://github.com/bdlukaa/fluent_ui/pull/1359)) - dynamically overflowed primary commands remain in the primary command collection and retain their labels, shortcut/help text, enabled state, and semantics when shown in the overflow menu diff --git a/example/lib/screens/surface/acrylic.dart b/example/lib/screens/surface/acrylic.dart index f6a0f0c4e..b132fd89d 100644 --- a/example/lib/screens/surface/acrylic.dart +++ b/example/lib/screens/surface/acrylic.dart @@ -3,26 +3,6 @@ import 'package:fluent_ui/fluent_ui.dart'; import '../settings.dart'; -const questionMark = Padding( - padding: EdgeInsetsDirectional.only(start: 4), - child: WindowsIcon(WindowsIcons.status_circle_question_mark, size: 14), -); - -InlineSpan _buildLabel(final String label, final String description) { - return TextSpan( - text: label, - children: [ - WidgetSpan( - child: Tooltip( - useMousePosition: false, - message: description, - child: questionMark, - ), - ), - ], - ); -} - class AcrylicPage extends StatefulWidget { const AcrylicPage({super.key}); @@ -33,208 +13,349 @@ class AcrylicPage extends StatefulWidget { class _AcrylicPageState extends State with PageMixin { double tintOpacity = 0.8; double luminosityOpacity = 0.8; - double blurAmout = 30; + double blurAmount = kBlurAmount; double elevation = 0; Color? color; + bool automaticLuminosity = true; + bool acrylicDisabled = false; + bool animateMaterial = false; @override - Widget build(final BuildContext context) { - final menuColor = FluentTheme.of( - context, - ).menuColor.withValues(alpha: kMenuColorOpacity); + Widget build(BuildContext context) { + final theme = FluentTheme.of(context); + final customTint = color ?? theme.acrylicBackgroundColor; + final luminosity = automaticLuminosity ? null : luminosityOpacity; return ScaffoldPage.scrollable( header: const PageHeader(title: Text('Acrylic')), children: [ const Text( - 'A translucent material recommended for panel backgrounds. Acrylic is a ' - 'type of Brush that creates a translucent texture. You can apply acrylic ' - 'to app surfaces to add depth and help establish a visual hierarchy.', + 'In-app Acrylic blurs Flutter content behind a clipped surface. This ' + 'lab keeps the same high-contrast backdrop under every sample so the ' + 'blur, luminosity, tint, noise, and solid fallback are easy to compare.', ), - subtitle(content: const Text('Default background acrylic brush.')), - const Card( - child: SizedBox( - height: 300, - width: 500, - child: Stack( - children: [ - _AcrylicChildren(), - Positioned.fill( - child: Padding( - padding: EdgeInsetsDirectional.all(12), - child: Acrylic(), - ), - ), - ], + subtitle(content: const Text('Default materials')), + const _MaterialComparison( + children: [ + _MaterialSample( + title: 'Acrylic', + child: Stack( + children: [ + _AcrylicBackdropContent(), + Positioned.fill(child: Acrylic(child: SizedBox.expand())), + ], + ), ), - ), + _MaterialSample( + title: 'Mica fallback', + child: Stack( + children: [ + _AcrylicBackdropContent(), + Positioned.fill(child: Mica(child: SizedBox.expand())), + ], + ), + ), + ], ), - subtitle(content: const Text('Custom acrylic brush.')), + subtitle(content: const Text('Acrylic rendering laboratory')), Card( - child: SizedBox( - height: 300, - width: 500, - child: Row( - children: [ - Expanded( - child: Stack( - children: [ - const _AcrylicChildren(), - Positioned.fill( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MaterialSample( + title: 'Configured Acrylic', + child: Stack( + children: [ + const _AcrylicBackdropContent(), + Positioned.fill( + child: Padding( + padding: const EdgeInsetsDirectional.all(12), + child: acrylicDisabled + ? DisableAcrylic( + child: Acrylic( + fallbackColor: theme.acrylicFallbackColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + ), + child: const SizedBox.expand(), + ), + ) + : Acrylic( + tint: customTint, + tintAlpha: tintOpacity, + luminosityAlpha: luminosity, + blurAmount: blurAmount, + elevation: elevation, + fallbackColor: theme.acrylicFallbackColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + ), + child: const SizedBox.expand(), + ), + ), + ), + const Positioned.fill( + child: IgnorePointer( child: Padding( - padding: const EdgeInsetsDirectional.all(12), - child: Acrylic( - tintAlpha: tintOpacity, - luminosityAlpha: luminosityOpacity, - blurAmount: blurAmout, - elevation: elevation, - tint: color, + padding: EdgeInsetsDirectional.all(28), + child: Align( + alignment: AlignmentDirectional.topStart, + child: Text( + 'Foreground content remains above the material', + style: TextStyle(fontWeight: FontWeight.w600), + ), ), ), ), - ], - ), + ), + ], ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InfoLabel.rich( - label: _buildLabel( - 'Tint color', - 'the color/tint overlay layer.', - ), - child: ComboBox( - placeholder: const Text('Tint color '), - onChanged: (final c) => setState(() => color = c), - value: color, - items: [ - ComboBoxItem( - value: menuColor, - child: Row( - children: [ - buildColorBox(menuColor), - const SizedBox(width: 10), - const Text('Acrylic background'), - ], - ), - ), - ComboBoxItem( - value: Colors.white, - child: Row( - children: [ - buildColorBox(Colors.white), - const SizedBox(width: 10), - const Text('White'), - ], - ), - ), - ComboBoxItem( - value: const Color(0xE4000000), - child: Row( - children: [ - buildColorBox(const Color(0xE4000000)), - const SizedBox(width: 10), - const Text('Black'), - ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 24, + runSpacing: 12, + children: [ + InfoLabel( + label: 'Tint color', + child: ComboBox( + value: color, + placeholder: const Text('Theme tint'), + onChanged: (value) => setState(() => color = value), + items: [ + ...Colors.accentColors.map( + (accent) => ComboBoxItem( + value: accent, + child: Text( + accentColorNames[Colors.accentColors.indexOf( + accent, + ) + + 1], ), ), - ...List.generate(Colors.accentColors.length, ( - final index, - ) { - final color = Colors.accentColors[index]; - return ComboBoxItem( - value: color, - child: Row( - children: [ - buildColorBox(color), - const SizedBox(width: 10), - Text(accentColorNames[index + 1]), - ], - ), - ); - }), - ], - ), + ), + ], ), - InfoLabel.rich( - label: _buildLabel( - 'Tint opacity', - 'the opacity of the tint layer.', - ), + ), + InfoLabel( + label: 'Tint opacity: ${tintOpacity.toStringAsFixed(2)}', + child: SizedBox( + width: 180, child: Slider( value: tintOpacity, max: 1, - onChanged: (final v) => setState(() => tintOpacity = v), + onChanged: (value) => + setState(() => tintOpacity = value), ), ), - InfoLabel.rich( - label: _buildLabel( - 'Tint luminosity opacity', - 'controls the amount of saturation that is allowed through ' - 'the acrylic surface from the background.', - ), + ), + InfoLabel( + label: + 'Luminosity opacity: ${luminosityOpacity.toStringAsFixed(2)}', + child: SizedBox( + width: 180, child: Slider( value: luminosityOpacity, max: 1, - onChanged: (final v) => - setState(() => luminosityOpacity = v), + onChanged: automaticLuminosity + ? null + : (value) => + setState(() => luminosityOpacity = value), ), ), - InfoLabel( - label: 'Blur amount', + ), + InfoLabel( + label: 'Blur: ${blurAmount.toStringAsFixed(0)}', + child: SizedBox( + width: 180, child: Slider( - value: blurAmout, - onChanged: (final v) => setState(() => blurAmout = v), + value: blurAmount, + max: 60, + onChanged: (value) => + setState(() => blurAmount = value), ), ), - InfoLabel( - label: 'Elevation', + ), + InfoLabel( + label: 'Elevation: ${elevation.toStringAsFixed(0)}', + child: SizedBox( + width: 180, child: Slider( value: elevation, max: 20, - onChanged: (final v) => setState(() => elevation = v), + onChanged: (value) => setState(() => elevation = value), ), ), - ], + ), + Checkbox( + checked: automaticLuminosity, + onChanged: (value) => + setState(() => automaticLuminosity = value ?? true), + content: const Text('Automatic luminosity'), + ), + Checkbox( + checked: acrylicDisabled, + onChanged: (value) => + setState(() => acrylicDisabled = value ?? false), + content: const Text('Disable Acrylic / fallback'), + ), + ], + ), + ], + ), + ), + subtitle(content: const Text('Animated Acrylic')), + Card( + child: Row( + children: [ + Expanded( + child: _MaterialSample( + title: 'Animation keeps the same material pipeline', + child: Stack( + children: [ + const _AcrylicBackdropContent(), + Positioned.fill( + child: AnimatedAcrylic( + duration: const Duration(milliseconds: 500), + tint: animateMaterial ? Colors.blue : Colors.magenta, + tintAlpha: animateMaterial ? 0.1 : 0.8, + + blurAmount: animateMaterial ? 45 : 20, + child: const SizedBox.expand(), + ), + ), + ], + ), ), - ], - ), + ), + const SizedBox(width: 16), + Button( + onPressed: () => + setState(() => animateMaterial = !animateMaterial), + child: Text(animateMaterial ? 'Reverse' : 'Animate'), + ), + ], ), ), ], ); } +} + +class _MaterialComparison extends StatelessWidget { + const _MaterialComparison({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return Wrap(spacing: 16, runSpacing: 16, children: children); + } +} + +class _MaterialSample extends StatelessWidget { + const _MaterialSample({required this.title, required this.child}); - Widget buildColorBox(final Color color) { - const boxSize = 16.0; - return Container( - height: boxSize, - width: boxSize, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 420, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Text( + title, + style: FluentTheme.of(context).typography.bodyStrong, + ), + ), + SizedBox(height: 220, child: child), + ], ), ); } } -class _AcrylicChildren extends StatelessWidget { - const _AcrylicChildren(); +class _AcrylicBackdropContent extends StatelessWidget { + const _AcrylicBackdropContent(); @override - Widget build(final BuildContext context) { - return Stack( - children: [ - Container(height: 200, width: 100, color: Colors.blue.lightest), - Align( - alignment: AlignmentDirectional.center, - child: Container(height: 152, width: 152, color: Colors.magenta), + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: AlignmentDirectional.topStart, + end: AlignmentDirectional.bottomEnd, + colors: [ + Color(0xFF080808), + Color(0xFFEEEEEE), + Color(0xFF0078D4), + Color(0xFFE81123), + ], + stops: [0, 0.32, 0.66, 1], ), - Align( - alignment: AlignmentDirectional.bottomEnd, - child: Container(height: 100, width: 80, color: Colors.yellow), - ), - ], + ), + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint(painter: _ContrastStripePainter()), + Align( + alignment: AlignmentDirectional.center, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.yellow, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.8), + blurRadius: 18, + offset: const Offset(8, 8), + ), + ], + ), + child: const Padding( + padding: EdgeInsetsDirectional.all(12), + child: Text( + 'Readable text\nsharp contrast + shadow', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const PositionedDirectional( + start: 12, + bottom: 12, + child: Text( + 'colorful backdrop', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ], + ), ); } } + +class _ContrastStripePainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.white.withValues(alpha: 0.24); + for (var x = -size.height; x < size.width; x += 24) { + canvas.drawRect(Rect.fromLTWH(x, 0, 10, size.height), paint); + } + } + + @override + bool shouldRepaint(covariant _ContrastStripePainter oldDelegate) => false; +} diff --git a/lib/src/controls/flyouts/flyout_content.dart b/lib/src/controls/flyouts/flyout_content.dart index 72c2ff4fe..abd7125ec 100644 --- a/lib/src/controls/flyouts/flyout_content.dart +++ b/lib/src/controls/flyouts/flyout_content.dart @@ -87,7 +87,6 @@ class FlyoutContent extends StatelessWidget { }(); final content = Acrylic( - tintAlpha: !useAcrylic ? 1.0 : null, shape: resolvedShape, child: Container( constraints: constraints, @@ -103,17 +102,19 @@ class FlyoutContent extends StatelessWidget { ), ); + final result = useAcrylic ? content : DisableAcrylic(child: content); + if (elevation > 0.0) { return PhysicalModel( elevation: elevation, color: Colors.transparent, borderRadius: resolvedBorderRadius?.resolve(textDirection), shadowColor: shadowColor, - child: content, + child: result, ); } - return content; + return result; } } diff --git a/lib/src/controls/flyouts/menu_flyout.dart b/lib/src/controls/flyouts/menu_flyout.dart index 0bf942904..4128b12fe 100644 --- a/lib/src/controls/flyouts/menu_flyout.dart +++ b/lib/src/controls/flyouts/menu_flyout.dart @@ -109,7 +109,7 @@ class _MenuFlyoutState extends State { shadowColor: widget.shadowColor, shape: widget.shape, padding: kDefaultMenuPadding, - useAcrylic: DisableAcrylic.of(context) != null, + useAcrylic: DisableAcrylic.of(context) == null, child: ScrollConfiguration( behavior: const _MenuScrollBehavior(), child: SingleChildScrollView( diff --git a/lib/src/controls/flyouts/teaching_tip.dart b/lib/src/controls/flyouts/teaching_tip.dart index 80ad1c0a7..f74e9f97c 100644 --- a/lib/src/controls/flyouts/teaching_tip.dart +++ b/lib/src/controls/flyouts/teaching_tip.dart @@ -259,7 +259,7 @@ class TeachingTip extends StatelessWidget { arrowMainAxisWidth: verticalPadding, ), child: Container( - color: theme.menuColor, + color: theme.menuColor.withValues(alpha: kMenuColorOpacity), padding: EdgeInsets.only( top: switch (flyout.placementMode) { FlyoutPlacementMode.bottomLeft || diff --git a/lib/src/controls/form/auto_suggest_box.dart b/lib/src/controls/form/auto_suggest_box.dart index fbe3328b3..8d1544a9d 100644 --- a/lib/src/controls/form/auto_suggest_box.dart +++ b/lib/src/controls/form/auto_suggest_box.dart @@ -1028,6 +1028,11 @@ class _AutoSuggestBoxOverlayState extends State<_AutoSuggestBoxOverlay> { final theme = FluentTheme.of(context); final localizations = FluentLocalizations.of(context); + final shape = RoundedRectangleBorder( + borderRadius: widget.showAbove + ? const BorderRadius.vertical(top: Radius.circular(4)) + : const BorderRadius.vertical(bottom: Radius.circular(4)), + ); return MediaQuery.withNoTextScaling( child: TextFieldTapRegion( @@ -1036,12 +1041,7 @@ class _AutoSuggestBoxOverlayState extends State<_AutoSuggestBoxOverlay> { child: Container( constraints: BoxConstraints(maxHeight: widget.maxHeight), decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: widget.showAbove - ? const BorderRadius.vertical(top: Radius.circular(4)) - : const BorderRadius.vertical(bottom: Radius.circular(4)), - ), - color: theme.resources.cardBackgroundFillColorDefault, + shape: shape, shadows: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), @@ -1058,6 +1058,8 @@ class _AutoSuggestBoxOverlayState extends State<_AutoSuggestBoxOverlay> { ], ), child: Acrylic( + shape: shape, + luminosityAlpha: theme.acrylicLuminosityOpacity, child: ValueListenableBuilder( valueListenable: widget.controller, builder: (context, value, _) { diff --git a/lib/src/controls/navigation/navigation_view/view.dart b/lib/src/controls/navigation/navigation_view/view.dart index 8c6270fa6..3356577f4 100644 --- a/lib/src/controls/navigation/navigation_view/view.dart +++ b/lib/src/controls/navigation/navigation_view/view.dart @@ -1154,7 +1154,7 @@ class NavigationViewState extends State { child: Acrylic( tint: tint ?? theme.overlayBackgroundColor, tintAlpha: tintAlpha ?? 0.9, - luminosityAlpha: luminosityAlpha ?? 0.9, + luminosityAlpha: luminosityAlpha, blurAmount: blurAmount ?? 50, shape: shape, child: child, diff --git a/lib/src/controls/pickers/pickers.dart b/lib/src/controls/pickers/pickers.dart index 1e63020d5..8f31aa557 100644 --- a/lib/src/controls/pickers/pickers.dart +++ b/lib/src/controls/pickers/pickers.dart @@ -422,21 +422,32 @@ class PickerState extends State { width: width.toDouble(), child: FadeTransition( opacity: primary, - child: Container( - height: widget.pickerHeight, - width: box.size.width, - decoration: ShapeDecoration( - color: theme.menuColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - side: BorderSide( - color: theme.resources.surfaceStrokeColorFlyout, - width: 0.6, - ), + child: Acrylic( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: theme.resources.surfaceStrokeColorFlyout, + width: 0.6, ), ), - child: MediaQuery.withNoTextScaling( - child: widget.pickerContent(context), + child: Container( + height: widget.pickerHeight, + width: box.size.width, + decoration: ShapeDecoration( + color: theme.menuColor.withValues( + alpha: kMenuColorOpacity, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide( + color: theme.resources.surfaceStrokeColorFlyout, + width: 0.6, + ), + ), + ), + child: MediaQuery.withNoTextScaling( + child: widget.pickerContent(context), + ), ), ), ), diff --git a/lib/src/controls/surfaces/acrylic.dart b/lib/src/controls/surfaces/acrylic.dart index 6d4f029c1..fd1846314 100644 --- a/lib/src/controls/surfaces/acrylic.dart +++ b/lib/src/controls/surfaces/acrylic.dart @@ -1,26 +1,33 @@ import 'dart:math' as math; -import 'dart:ui' as ui show Image; import 'dart:ui' show ImageFilter; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:material_ui/material_ui.dart' as m; /// The default blur amount applied to [Acrylic] widgets. const double kBlurAmount = 30; -/// The default tint alpha for [Acrylic] widgets. +/// The legacy default tint alpha for [Acrylic] widgets. +/// +/// New [Acrylic] instances resolve their default from +/// [FluentThemeData.acrylicTintOpacity]. const double kDefaultAcrylicAlpha = 0.8; -/// The default opacity of the [FluentThemeData.menuColor] +/// The default opacity of the [FluentThemeData.menuColor]. const double kMenuColorOpacity = 0.65; -/// A translucent material that applies a blur effect to content behind it. +/// An in-app Acrylic material that blurs Flutter content behind it. +/// +/// This widget corresponds to WinUI's in-app `AcrylicBrush`, not +/// `DesktopAcrylicBackdrop`. It cannot sample arbitrary windows or the desktop +/// wallpaper behind the Flutter window. The material clips the backdrop to +/// [shape], applies WinUI's luminosity and tint stages, and adds the +/// native-style tiled 2% noise layer. When effects are disabled, it paints the +/// theme's opaque fallback instead of creating a filter or noise layer. /// -/// Acrylic is a Fluent Design material that creates depth and visual hierarchy -/// by allowing background content to show through with a blur and tint effect. -/// It's commonly used for navigation panes, command bars, and other surfaces -/// that overlay content. +/// Acrylic is intended for transient and supporting surfaces. It is more +/// expensive than [Mica] because it filters pixels behind the widget; avoid +/// stacking large or overlapping Acrylic surfaces. /// /// ![Acrylic Example](https://learn.microsoft.com/en-us/windows/apps/design/style/images/acrylic_lighttheme_base.png) /// @@ -39,27 +46,17 @@ const double kMenuColorOpacity = 0.65; /// ``` /// {@end-tool} /// -/// ## Acrylic recipe -/// -/// The acrylic effect is created by layering several elements: -/// -/// 1. **Blur** - Applies a Gaussian blur to background content -/// 2. **Tint** - A semi-transparent color overlay -/// 3. **Luminosity** - Adjusts the luminosity of the tinted background -/// 4. **Noise texture** - Adds subtle visual texture +/// When [luminosityAlpha] is null, Acrylic uses WinUI's automatic luminosity +/// calculation. Set it explicitly to use a fixed luminosity opacity. /// -/// ## Performance considerations -/// -/// Acrylic uses [BackdropFilter] which can be expensive. Consider: -/// -/// * Using [Mica] instead for large surfaces -/// * Reducing [blurAmount] on lower-end devices -/// * Avoiding nested acrylic surfaces +/// When [DisableAcrylic] is present, the widget keeps the same layout and +/// semantics but paints only [fallbackColor], without a backdrop filter or +/// noise texture. /// /// See also: /// -/// * [Mica], a lighter-weight material effect -/// * [Card], a surface that doesn't blur background content +/// * [Mica], an opaque foundation material that does not blur Flutter content +/// * [Card], a surface that does not blur background content /// * class Acrylic extends StatefulWidget { /// Creates an acrylic surface. @@ -72,30 +69,36 @@ class Acrylic extends StatefulWidget { this.blurAmount, this.shape, this.shadowColor, + this.fallbackColor, this.elevation = 0.0, }); - /// The tint to apply to the acrylic layers. + /// The tint color applied by the Acrylic brush. /// - /// Defaults to the acrylicBackgroundColor from the nearest [FluentTheme]. + /// Equivalent to WinUI `AcrylicBrush.TintColor`. Defaults to the tint + /// resource from the nearest [FluentTheme]. final Color? tint; - /// The opacity applied to the [tint] from 0.0 to 1.0. + /// The opacity applied to [tint]. /// - /// Defaults to 0.8. + /// Equivalent to WinUI `AcrylicBrush.TintOpacity`. If null, the theme's + /// default material opacity is used. final double? tintAlpha; - /// The child contained by this box + /// The child contained by this box. final Widget? child; - /// The opacity applied to the luminosity layer of the acrylic, from 0.0 to 1.0. + /// The opacity applied to the luminosity stage. /// - /// Defaults to 0.8. + /// Equivalent to WinUI `AcrylicBrush.TintLuminosityOpacity`. A null value + /// selects WinUI's automatic luminosity behavior based on [tint] and + /// [tintAlpha]. final double? luminosityAlpha; - /// The amount of blur to apply to the content behind the acrylic. + /// The amount of Gaussian blur to apply to content behind the acrylic. /// - /// Defaults to 30. + /// WinUI uses a 30px blur for Acrylic by default. This property is a + /// Flutter-specific adjustment for platform and density differences. final double? blurAmount; /// The shape of the acrylic. @@ -103,12 +106,19 @@ class Acrylic extends StatefulWidget { /// Defaults to a square [RoundedRectangleBorder]. final ShapeBorder? shape; - /// The color of the elevation + /// The color used for the elevation shadow. /// - /// Defaults to the shadowColor from the nearest [FluentTheme]. + /// Defaults to the shadow color from the nearest [FluentTheme]. final Color? shadowColor; - /// The z-coordinate relative to the parent at which to place this physical object. + /// The solid color used when Acrylic is disabled or cannot be rendered. + /// + /// Equivalent to WinUI `AcrylicBrush.FallbackColor`. If null, the + /// Acrylic fallback resource from the nearest [FluentTheme] is used. + final Color? fallbackColor; + + /// The z-coordinate relative to the parent at which to place this physical + /// object. /// /// The value is non-negative. Defaults to 0. final double elevation; @@ -123,6 +133,7 @@ class Acrylic extends StatefulWidget { ..add(DoubleProperty('blurAmount', blurAmount)) ..add(DiagnosticsProperty('shape', shape)) ..add(ColorProperty('shadowColor', shadowColor)) + ..add(ColorProperty('fallbackColor', fallbackColor)) ..add(DoubleProperty('elevation', elevation)); } @@ -135,18 +146,6 @@ class _AcrylicState extends State { ImageFilter? _cachedBlurFilter; double _cachedBlurAmount = kBlurAmount; - @override - void initState() { - super.initState(); - _NoiseTextureCacher._instance ??= _NoiseTextureCacher._new(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _updateProperties(); - setState(() {}); - } - }); - } - @override void didUpdateWidget(Acrylic old) { super.didUpdateWidget(old); @@ -164,23 +163,25 @@ class _AcrylicState extends State { bool _compareAcrylics(Acrylic other) { return widget.blurAmount != other.blurAmount || - widget.elevation != other.elevation || widget.luminosityAlpha != other.luminosityAlpha || widget.shape != other.shape || widget.tint != other.tint || - widget.tintAlpha != other.tintAlpha; + widget.tintAlpha != other.tintAlpha || + widget.fallbackColor != other.fallbackColor; } void _updateProperties() { - final blurAmount = widget.blurAmount ?? 30; + final theme = FluentTheme.of(context); + final blurAmount = math.max(widget.blurAmount ?? kBlurAmount, 0).toDouble(); _properties = AcrylicProperties( - tint: widget.tint ?? FluentTheme.of(context).acrylicBackgroundColor, - tintAlpha: widget.tintAlpha ?? kDefaultAcrylicAlpha, - luminosityAlpha: widget.luminosityAlpha ?? kDefaultAcrylicAlpha, + tint: widget.tint ?? theme.acrylicBackgroundColor, + tintAlpha: widget.tintAlpha ?? theme.acrylicTintOpacity, + luminosityAlpha: widget.luminosityAlpha, blurAmount: blurAmount, shape: widget.shape ?? const RoundedRectangleBorder(), + fallbackColor: widget.fallbackColor ?? theme.acrylicFallbackColor, ); - // Only recreate the filter when blur amount changes + if (_cachedBlurFilter == null || _cachedBlurAmount != blurAmount) { _cachedBlurAmount = blurAmount; _cachedBlurFilter = ImageFilter.blur( @@ -190,9 +191,10 @@ class _AcrylicState extends State { } } - ImageFilter get blurFilter => - _cachedBlurFilter ?? - ImageFilter.blur(sigmaX: _cachedBlurAmount, sigmaY: _cachedBlurAmount); + ImageFilter get blurFilter => _cachedBlurFilter ??= ImageFilter.blur( + sigmaX: _cachedBlurAmount, + sigmaY: _cachedBlurAmount, + ); @override Widget build(BuildContext context) { @@ -200,7 +202,7 @@ class _AcrylicState extends State { assert(widget.elevation >= 0, 'The elevation must be always positive'); assert(_properties.tintAlpha >= 0, 'The tintAlpha must be always positive'); assert( - _properties.luminosityAlpha >= 0, + _properties.luminosityAlpha == null || _properties.luminosityAlpha! >= 0, 'The luminosityAlpha must be always positive', ); @@ -213,7 +215,6 @@ class _AcrylicState extends State { decoration: ShapeDecoration( shape: _properties.shape, shadows: [ - /* The shadows were taken from the official FluentUI design kit on Figma */ BoxShadow( color: shadowColor.withValues(alpha: 0.13), blurRadius: 0.9 * widget.elevation, @@ -226,13 +227,7 @@ class _AcrylicState extends State { ), ], ), - child: _AcrylicGuts( - child: m.Material( - type: m.MaterialType.transparency, - shape: widget.shape, - child: widget.child, - ), - ), + child: _AcrylicGuts(child: widget.child ?? const SizedBox.shrink()), ), ); } @@ -240,6 +235,9 @@ class _AcrylicState extends State { /// An animated acrylic widget. /// +/// The animation interpolates the same material properties as [Acrylic], while +/// retaining the cached blur and noise resources between frames. +/// /// See also: /// /// * [Acrylic], the non-animated version of this widget @@ -256,46 +254,38 @@ class AnimatedAcrylic extends ImplicitlyAnimatedWidget { this.blurAmount, this.shape, this.shadowColor, + this.fallbackColor, this.elevation = 0.0, super.curve, }); - /// The tint to apply to the acrylic layers. - /// - /// Defaults to the acrylicBackgroundColor from the nearest [FluentTheme]. + /// The tint color applied by the Acrylic brush. final Color? tint; - /// The opacity applied to the [tint] from 0.0 to 1.0. - /// - /// Defaults to 0.8. + /// The opacity applied to [tint]. final double? tintAlpha; - /// The child contained by this box + /// The child contained by this box. final Widget? child; - /// The opacity applied to the luminosity layer of the acrylic, from 0.0 to 1.0. - /// - /// Defaults to 0.8. + /// The opacity applied to the luminosity stage. Null selects automatic + /// WinUI luminosity behavior. final double? luminosityAlpha; - /// The amount of blur to apply to the content behind the acrylic. - /// - /// Defaults to 30. + /// The amount of blur applied to content behind the acrylic. final double? blurAmount; /// The shape of the acrylic. - /// - /// Defaults to a square [RoundedRectangleBorder]. final ShapeBorder? shape; - /// The color of the elevation - /// - /// Defaults to the shadowColor from the nearest [FluentTheme]. + /// The color of the elevation shadow. final Color? shadowColor; - /// The z-coordinate relative to the parent at which to place this physical object. - /// - /// The value is non-negative. Defaults to 0. + /// The solid fallback color used when Acrylic is disabled. + final Color? fallbackColor; + + /// The z-coordinate relative to the parent at which to place this physical + /// object. final double elevation; @override @@ -308,6 +298,7 @@ class AnimatedAcrylic extends ImplicitlyAnimatedWidget { ..add(DoubleProperty('blurAmount', blurAmount)) ..add(DiagnosticsProperty('shape', shape)) ..add(ColorProperty('shadowColor', shadowColor)) + ..add(ColorProperty('fallbackColor', fallbackColor)) ..add(DoubleProperty('elevation', elevation, defaultValue: 0.0)); } @@ -321,9 +312,10 @@ class _AnimatedAcrylicState extends AnimatedWidgetBaseState { Tween? _tintAlpha; Tween? _luminosityAlpha; Tween? _blurAmount; - m.ShapeBorderTween? _shape; + _ShapeBorderTween? _shape; ColorTween? _shadowColor; - Tween? _elevation; + ColorTween? _fallbackColor; + Tween? _elevation; @override void forEachTween(TweenVisitor visitor) { @@ -331,50 +323,59 @@ class _AnimatedAcrylicState extends AnimatedWidgetBaseState { visitor( _tint, widget.tint, - (dynamic value) => ColorTween(begin: value as Color), + (dynamic value) => ColorTween(begin: value as Color?), ) as ColorTween?; _tintAlpha = visitor( _tintAlpha, widget.tintAlpha, - (dynamic value) => Tween(begin: value as double), + (dynamic value) => Tween(begin: value as double?), ) - as Tween?; + as Tween?; _luminosityAlpha = visitor( _luminosityAlpha, widget.luminosityAlpha, - (dynamic value) => Tween(begin: value as double), + (dynamic value) => Tween(begin: value as double?), ) - as Tween?; + as Tween?; _blurAmount = visitor( _blurAmount, widget.blurAmount, - (dynamic value) => Tween(begin: value as double), + (dynamic value) => Tween(begin: value as double?), ) - as Tween?; + as Tween?; _shape = visitor( _shape, widget.shape, - (dynamic value) => - m.ShapeBorderTween(begin: value as ShapeBorder), + (dynamic value) => _ShapeBorderTween( + begin: value as ShapeBorder?, + end: widget.shape, + ), ) - as m.ShapeBorderTween?; + as _ShapeBorderTween?; _shadowColor = visitor( _shadowColor, widget.shadowColor, - (dynamic value) => ColorTween(begin: value as Color), + (dynamic value) => ColorTween(begin: value as Color?), + ) + as ColorTween?; + _fallbackColor = + visitor( + _fallbackColor, + widget.fallbackColor, + (dynamic value) => ColorTween(begin: value as Color?), ) as ColorTween?; _elevation = visitor( _elevation, widget.elevation, - (dynamic value) => Tween(begin: value as double), + (dynamic value) => Tween(begin: value as double?), ) as Tween?; } @@ -388,13 +389,21 @@ class _AnimatedAcrylicState extends AnimatedWidgetBaseState { blurAmount: _blurAmount?.evaluate(animation), shape: _shape?.evaluate(animation), shadowColor: _shadowColor?.evaluate(animation), + fallbackColor: _fallbackColor?.evaluate(animation), elevation: _elevation?.evaluate(animation) ?? 0, child: widget.child, ); } } -/// Represents the properties of an Acrylic material +class _ShapeBorderTween extends Tween { + _ShapeBorderTween({super.begin, super.end}); + + @override + ShapeBorder? lerp(double t) => ShapeBorder.lerp(begin, end, t); +} + +/// Represents the resolved properties of an Acrylic material. @immutable class AcrylicProperties { /// The tint color of the acrylic. @@ -403,8 +412,8 @@ class AcrylicProperties { /// The opacity of the tint color. final double tintAlpha; - /// The opacity of the luminosity color. - final double luminosityAlpha; + /// The opacity of the luminosity color, or null for automatic behavior. + final double? luminosityAlpha; /// The amount of blur to apply to the content behind the acrylic. final double blurAmount; @@ -412,6 +421,9 @@ class AcrylicProperties { /// The shape of the acrylic. final ShapeBorder shape; + /// The solid fallback color. + final Color fallbackColor; + /// Creates a new instance of [AcrylicProperties]. const AcrylicProperties({ required this.tint, @@ -419,34 +431,40 @@ class AcrylicProperties { required this.luminosityAlpha, required this.blurAmount, required this.shape, + this.fallbackColor = Colors.black, }); /// Creates a new instance of [AcrylicProperties] with default values. const AcrylicProperties.empty() : tint = Colors.black, tintAlpha = kDefaultAcrylicAlpha, - luminosityAlpha = kDefaultAcrylicAlpha, + luminosityAlpha = null, blurAmount = kBlurAmount, - shape = const RoundedRectangleBorder(); + shape = const RoundedRectangleBorder(), + fallbackColor = Colors.black; @override - int get hashCode => - Object.hash(tint, tintAlpha, luminosityAlpha, blurAmount, shape); + int get hashCode => Object.hash( + tint, + tintAlpha, + luminosityAlpha, + blurAmount, + shape, + fallbackColor, + ); @override bool operator ==(Object other) { - if (other is AcrylicProperties) { - return tint == other.tint && - tintAlpha == other.tintAlpha && - luminosityAlpha == other.luminosityAlpha && - blurAmount == other.blurAmount && - shape == other.shape; - } - - return false; + return other is AcrylicProperties && + tint == other.tint && + tintAlpha == other.tintAlpha && + luminosityAlpha == other.luminosityAlpha && + blurAmount == other.blurAmount && + shape == other.shape && + fallbackColor == other.fallbackColor; } - /// Gets the properties of the acrylic from the context. + /// Gets the resolved properties of the acrylic from the context. static AcrylicProperties of(BuildContext context) { return context .dependOnInheritedWidgetOfExactType<_AcrylicInheritedWidget>()! @@ -461,9 +479,7 @@ class _AcrylicInheritedWidget extends InheritedWidget { const _AcrylicInheritedWidget({required this.state, required super.child}); @override - bool updateShouldNotify(_AcrylicInheritedWidget old) { - return state != old.state; - } + bool updateShouldNotify(_AcrylicInheritedWidget old) => state != old.state; } class _AcrylicGuts extends StatelessWidget { @@ -476,62 +492,78 @@ class _AcrylicGuts extends StatelessWidget { final inherited = context .dependOnInheritedWidgetOfExactType<_AcrylicInheritedWidget>()!; final properties = inherited.state._properties; - final tint = AcrylicHelper.getEffectiveTintColor( + final disabled = DisableAcrylic.of(context) != null; + + if (disabled) { + return ClipPath( + clipper: ShapeBorderClipper(shape: properties.shape), + child: DecoratedBox( + decoration: BoxDecoration(color: properties.fallbackColor), + child: child, + ), + ); + } + + final tintColor = AcrylicHelper.getEffectiveTintColor( properties.tint, - AcrylicHelper.getTintOpacityModifier(properties.tint), + properties.tintAlpha, + luminosityOpacity: properties.luminosityAlpha, + ); + final luminosityColor = AcrylicHelper.getLuminosityColor( + properties.tint, + properties.luminosityAlpha, + properties.tintAlpha, ); - - final disabled = DisableAcrylic.of(context) != null; return ClipPath( clipper: ShapeBorderClipper(shape: properties.shape), - child: CustomPaint( - isComplex: true, - painter: _AcrylicPainter( - tintColor: disabled ? tint.withValues(alpha: 1) : tint, - luminosityColor: AcrylicHelper.getLuminosityColor( - tint, - disabled ? 1.0 : properties.luminosityAlpha, + child: Stack( + fit: StackFit.passthrough, + children: [ + Positioned.fill( + child: BackdropFilter( + filter: inherited.state.blurFilter, + child: const SizedBox.expand(), + ), ), - ), - child: disabled - ? child - : BackdropFilter( - filter: inherited.state.blurFilter, - child: Stack( - fit: StackFit.passthrough, - children: [ - const Opacity( - opacity: 0.02, - child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage( - 'assets/AcrylicNoise.png', - package: 'fluent_ui', - ), - alignment: AlignmentDirectional.topStart, - repeat: ImageRepeat.repeat, - ), - backgroundBlendMode: BlendMode.srcOver, - color: Colors.transparent, - ), - ), - ), - child, - ], - ), + Positioned.fill( + child: CustomPaint( + isComplex: true, + painter: _AcrylicPainter( + tintColor: tintColor, + luminosityColor: luminosityColor, ), + ), + ), + const Positioned.fill(child: _AcrylicNoise()), + child, + ], ), ); } } -class _AcrylicPainter extends CustomPainter { - static final Color red = const Color(0xFFFF0000).withValues(alpha: 0.12); - static final Color blue = const Color(0xFF00FF00).withValues(alpha: 0.12); - static final Color green = const Color(0xFF0000FF).withValues(alpha: 0.12); +class _AcrylicNoise extends StatelessWidget { + const _AcrylicNoise(); + @override + Widget build(BuildContext context) { + return const Opacity( + opacity: 0.02, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/AcrylicNoise.png', package: 'fluent_ui'), + alignment: AlignmentDirectional.topStart, + repeat: ImageRepeat.repeat, + ), + ), + ), + ); + } +} + +class _AcrylicPainter extends CustomPainter { final Color luminosityColor; final Color tintColor; @@ -542,15 +574,11 @@ class _AcrylicPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - canvas - ..drawColor(luminosityColor, BlendMode.luminosity) - ..drawColor(red, BlendMode.saturation) - ..drawColor(blue, BlendMode.saturation) - ..drawColor(green, BlendMode.saturation) - ..drawColor( - tintColor, - tintColor.a == 1 ? BlendMode.srcIn : BlendMode.color, - ); + canvas.drawColor(luminosityColor, BlendMode.luminosity); + canvas.drawColor( + tintColor, + tintColor.a >= 1 ? BlendMode.src : BlendMode.color, + ); } @override @@ -559,124 +587,102 @@ class _AcrylicPainter extends CustomPainter { } } -// Credits: @HrX03 (https://github.com/hrx03) -/// Microsoft utils converted from C# to dart +/// Microsoft Acrylic helper calculations converted from WinUI's +/// `AcrylicBrush` implementation. class AcrylicHelper { - /// Gets the effective tint color of the acrylic. - static Color getEffectiveTintColor(Color color, double opacity) { - // Update tintColor's alpha with the combined opacity value - // If LuminosityOpacity was specified, we don't intervene into users parameters - return color.withValues(alpha: opacity); + /// Gets the effective tint color of Acrylic. + /// + /// [tintOpacity] is equivalent to WinUI `TintOpacity`. When + /// [luminosityOpacity] is null, the WinUI tint opacity modifier is applied. + static Color getEffectiveTintColor( + Color color, + double tintOpacity, { + double? luminosityOpacity, + }) { + final opacity = clampDouble(tintOpacity, 0, 1); + final modifier = luminosityOpacity == null + ? getTintOpacityModifier(color) + : 1.0; + return color.withValues(alpha: color.a * opacity * modifier); } - /// Gets the luminosity color of the acrylic. - static Color getLuminosityColor(Color tintColor, double? luminosityOpacity) { - // If luminosity opacity is specified, just use the values as is - if (luminosityOpacity != null) { - return tintColor.withValues(alpha: clampDouble(luminosityOpacity, 0, 1)); - } else { - // To create the Luminosity blend input color without luminosity opacity, - // we're taking the TintColor input, converting to HSV, and clamping the V between these values - const minHsvV = 0.125; - const maxHsvV = 0.965; - - final hsvTintColor = HSVColor.fromColor(tintColor); - - final clampedHsvV = clampDouble(hsvTintColor.value, minHsvV, maxHsvV); + /// Gets the luminosity color of Acrylic. + /// + /// [tintOpacity] is applied to the original tint before the luminosity + /// calculation, matching WinUI's `GetEffectiveLuminosityColor`. + static Color getLuminosityColor( + Color tintColor, + double? luminosityOpacity, [ + double tintOpacity = 1, + ]) { + final sourceTint = tintColor.withValues( + alpha: tintColor.a * clampDouble(tintOpacity, 0, 1), + ); - final hsvLuminosityColor = hsvTintColor.withValue(clampedHsvV); - final rgbLuminosityColor = hsvLuminosityColor.toColor(); + if (luminosityOpacity != null) { + return sourceTint.withValues(alpha: clampDouble(luminosityOpacity, 0, 1)); + } - // Now figure out luminosity opacity - // Map original *tint* opacity to this range - const minLuminosityOpacity = 0.15; - const maxLuminosityOpacity = 1.03; + const minHsvV = 0.125; + const maxHsvV = 0.965; + final hsvTintColor = HSVColor.fromColor(sourceTint); + final clampedHsvV = clampDouble(hsvTintColor.value, minHsvV, maxHsvV); + final rgbLuminosityColor = hsvTintColor.withValue(clampedHsvV).toColor(); - const luminosityOpacityRangeMax = - maxLuminosityOpacity - minLuminosityOpacity; - final mappedTintOpacity = - ((tintColor.a / 255.0) * luminosityOpacityRangeMax) + - minLuminosityOpacity; + const minLuminosityOpacity = 0.15; + const maxLuminosityOpacity = 1.03; + final mappedTintOpacity = + (sourceTint.a * (maxLuminosityOpacity - minLuminosityOpacity)) + + minLuminosityOpacity; - return rgbLuminosityColor.withValues( - alpha: math.min(mappedTintOpacity, 1), - ); - } + return rgbLuminosityColor.withValues(alpha: math.min(mappedTintOpacity, 1)); } - /// Gets the opacity modifier of the tint color. + /// Gets WinUI's tint opacity modifier for [color]. + /// + /// The modifier suppresses tint opacity as HSV value moves away from 50%, + /// while saturated colors receive less suppression. The deviation is the + /// absolute distance from the midpoint, as in WinUI's implementation. static double getTintOpacityModifier(Color color) { - // Mid point of HsvV range that these calculations are based on. This is here for easy tuning. const midPoint = 0.50; - - const whiteMaxOpacity = 0.45; // 100% luminosity - const midPointMaxOpacity = 0.90; // 50% luminosity - const blackMaxOpacity = 0.85; // 0% luminosity + const whiteMaxOpacity = 0.45; + const midPointMaxOpacity = 0.90; + const blackMaxOpacity = 0.85; final hsv = HSVColor.fromColor(color); - var opacityModifier = midPointMaxOpacity; if (hsv.value != midPoint) { - // Determine maximum suppression amount var lowestMaxOpacity = midPointMaxOpacity; var maxDeviation = midPoint; if (hsv.value > midPoint) { - lowestMaxOpacity = whiteMaxOpacity; // At white (100% hsvV) + lowestMaxOpacity = whiteMaxOpacity; maxDeviation = 1 - maxDeviation; - } else if (hsv.value < midPoint) { - lowestMaxOpacity = blackMaxOpacity; // At black (0% hsvV) + } else { + lowestMaxOpacity = blackMaxOpacity; } var maxOpacitySuppression = midPointMaxOpacity - lowestMaxOpacity; + final normalizedDeviation = (hsv.value - midPoint).abs() / maxDeviation; - // Determine normalized deviation from the midpoint - final deviation = hsv.value - midPoint; - final normalizedDeviation = deviation / maxDeviation; - - // If we have saturation, reduce opacity suppression to allow that color to come through more if (hsv.saturation > 0) { - // Dampen opacity suppression based on how much saturation there is maxOpacitySuppression *= math.max(1 - (hsv.saturation * 2), 0.0); } - final opacitySuppression = maxOpacitySuppression * normalizedDeviation; - - opacityModifier = midPointMaxOpacity - opacitySuppression; + opacityModifier = + midPointMaxOpacity - (maxOpacitySuppression * normalizedDeviation); } return opacityModifier; } } -class _NoiseTextureCacher { - static _NoiseTextureCacher? _instance; - - ui.Image? texture; - - _NoiseTextureCacher._new() { - _computeImage(); - } - - Future _computeImage() async { - const ImageProvider provider = AssetImage( - 'assets/AcrylicNoise.png', - package: 'fluent_ui', - ); - - provider - .resolve(ImageConfiguration.empty) - .addListener( - ImageStreamListener((image, synchronousCall) { - texture = image.image; - }), - ); - } -} - /// A widget that disables the acrylic effect for its descendants. /// +/// The descendant Acrylic widgets retain their layout and paint their +/// theme-resolved [Acrylic.fallbackColor] as an opaque solid surface. +/// /// See also: /// /// * [Acrylic], the widget that applies the acrylic effect @@ -690,7 +696,5 @@ class DisableAcrylic extends InheritedWidget { } @override - bool updateShouldNotify(DisableAcrylic oldWidget) { - return true; - } + bool updateShouldNotify(DisableAcrylic oldWidget) => true; } diff --git a/lib/src/controls/surfaces/mica.dart b/lib/src/controls/surfaces/mica.dart index f2990e93b..d213b3906 100644 --- a/lib/src/controls/surfaces/mica.dart +++ b/lib/src/controls/surfaces/mica.dart @@ -1,10 +1,16 @@ import 'package:fluent_ui/fluent_ui.dart'; -/// An opaque material that uses the desktop wallpaper as a subtle backdrop. +/// An opaque Mica foundation surface. /// -/// Mica is a Fluent Design material that creates visual hierarchy by -/// incorporating the user's desktop wallpaper into the app background. -/// It's optimized for performance, sampling the wallpaper only once. +/// This widget is the cross-platform Flutter representation and fallback for +/// WinUI Mica. Native WinUI Mica uses an OS system-backdrop controller to +/// incorporate desktop wallpaper and active/inactive window state; a pure +/// Flutter widget cannot sample that wallpaper. This implementation therefore +/// paints the theme-resolved opaque Mica color without a backdrop blur. +/// +/// Mica is intended for long-lived app foundation surfaces. Use [Acrylic] for +/// transient or supporting surfaces that need to reveal Flutter content behind +/// them. /// /// ![Mica Header Preview](https://learn.microsoft.com/en-us/windows/apps/design/style/images/materials/mica-header.png) /// @@ -50,9 +56,10 @@ class Mica extends StatelessWidget { /// The value is non-negative. final double elevation; - /// The color to paint the background area with. + /// The opaque color to paint the background area with. /// - /// If null, [FluentThemeData.micaBackgroundColor] is used. + /// If null, [FluentThemeData.micaBackgroundColor] is used. This is a + /// Flutter fallback color, not a wallpaper sample. final Color? backgroundColor; /// The border radius applied to the area. diff --git a/lib/src/styles/theme.dart b/lib/src/styles/theme.dart index 8c559b0ea..70f295663 100644 --- a/lib/src/styles/theme.dart +++ b/lib/src/styles/theme.dart @@ -280,9 +280,29 @@ class FluentThemeData with Diagnosticable { /// The background color for [ScaffoldPage] and similar scaffolding widgets. final Color scaffoldBackgroundColor; - /// The default background color for [Acrylic] widgets. + /// The default Acrylic tint color. + /// + /// This property is retained as the public background-color name for source + /// compatibility. It corresponds to WinUI `AcrylicBrush.TintColor`. final Color acrylicBackgroundColor; + /// The default Acrylic tint opacity. + /// + /// This corresponds to WinUI `AcrylicBrush.TintOpacity`. + final double acrylicTintOpacity; + + /// The default Acrylic luminosity opacity resource. + /// + /// This corresponds to WinUI `AcrylicBrush.TintLuminosityOpacity` for + /// theme-provided Acrylic surfaces. A widget-level null + /// [Acrylic.luminosityAlpha] still means automatic luminosity behavior. + final double acrylicLuminosityOpacity; + + /// The default opaque Acrylic fallback color. + /// + /// This corresponds to WinUI `AcrylicBrush.FallbackColor`. + final Color acrylicFallbackColor; + /// The default background color for [Mica] widgets. final Color micaBackgroundColor; @@ -398,6 +418,9 @@ class FluentThemeData with Diagnosticable { Color? inactiveBackgroundColor, Color? scaffoldBackgroundColor, Color? acrylicBackgroundColor, + double? acrylicTintOpacity, + double? acrylicLuminosityOpacity, + Color? acrylicFallbackColor, Color? micaBackgroundColor, Color? shadowColor, Color? menuColor, @@ -448,8 +471,13 @@ class FluentThemeData with Diagnosticable { shadowColor ??= isLight ? Colors.black : Colors.grey[130]; scaffoldBackgroundColor ??= resources.layerOnAcrylicFillColorDefault; acrylicBackgroundColor ??= isLight - ? resources.layerOnAcrylicFillColorDefault - : const Color(0xFF2c2c2c); + ? const Color(0xFFFCFCFC) + : const Color(0xFF2C2C2C); + acrylicTintOpacity ??= isLight ? 0.0 : 0.15; + acrylicLuminosityOpacity ??= isLight ? 0.85 : 0.96; + acrylicFallbackColor ??= isLight + ? const Color(0xFFF9F9F9) + : const Color(0xFF2C2C2C); micaBackgroundColor ??= resources.solidBackgroundFillColorBase; menuColor ??= isLight ? const Color(0xFFf9f9f9) : const Color(0xFF2c2c2c); cardColor ??= resources.cardBackgroundFillColorDefault; @@ -496,6 +524,9 @@ class FluentThemeData with Diagnosticable { inactiveBackgroundColor: inactiveBackgroundColor, scaffoldBackgroundColor: scaffoldBackgroundColor, acrylicBackgroundColor: acrylicBackgroundColor, + acrylicTintOpacity: acrylicTintOpacity, + acrylicLuminosityOpacity: acrylicLuminosityOpacity, + acrylicFallbackColor: acrylicFallbackColor, micaBackgroundColor: micaBackgroundColor, shadowColor: shadowColor, buttonTheme: buttonTheme, @@ -557,6 +588,9 @@ class FluentThemeData with Diagnosticable { required this.cardColor, required this.resources, required this.selectionColor, + this.acrylicTintOpacity = 0.15, + this.acrylicLuminosityOpacity = 0.96, + this.acrylicFallbackColor = const Color(0xFF2C2C2C), }); /// Creates a default light theme. @@ -610,6 +644,17 @@ class FluentThemeData with Diagnosticable { b.acrylicBackgroundColor, t, )!, + acrylicTintOpacity: + a.acrylicTintOpacity + + (b.acrylicTintOpacity - a.acrylicTintOpacity) * t, + acrylicLuminosityOpacity: + a.acrylicLuminosityOpacity + + (b.acrylicLuminosityOpacity - a.acrylicLuminosityOpacity) * t, + acrylicFallbackColor: Color.lerp( + a.acrylicFallbackColor, + b.acrylicFallbackColor, + t, + )!, micaBackgroundColor: Color.lerp( a.micaBackgroundColor, b.micaBackgroundColor, @@ -696,6 +741,9 @@ class FluentThemeData with Diagnosticable { Color? inactiveBackgroundColor, Color? scaffoldBackgroundColor, Color? acrylicBackgroundColor, + double? acrylicTintOpacity, + double? acrylicLuminosityOpacity, + Color? acrylicFallbackColor, Color? micaBackgroundColor, Color? shadowColor, Color? menuColor, @@ -737,6 +785,10 @@ class FluentThemeData with Diagnosticable { scaffoldBackgroundColor ?? this.scaffoldBackgroundColor, acrylicBackgroundColor: acrylicBackgroundColor ?? this.acrylicBackgroundColor, + acrylicTintOpacity: acrylicTintOpacity ?? this.acrylicTintOpacity, + acrylicLuminosityOpacity: + acrylicLuminosityOpacity ?? this.acrylicLuminosityOpacity, + acrylicFallbackColor: acrylicFallbackColor ?? this.acrylicFallbackColor, micaBackgroundColor: micaBackgroundColor ?? this.micaBackgroundColor, menuColor: menuColor ?? this.menuColor, cardColor: cardColor ?? this.cardColor, @@ -781,6 +833,11 @@ class FluentThemeData with Diagnosticable { ..add(ColorProperty('shadowColor', shadowColor)) ..add(ColorProperty('scaffoldBackgroundColor', scaffoldBackgroundColor)) ..add(ColorProperty('acrylicBackgroundColor', acrylicBackgroundColor)) + ..add(DoubleProperty('acrylicTintOpacity', acrylicTintOpacity)) + ..add( + DoubleProperty('acrylicLuminosityOpacity', acrylicLuminosityOpacity), + ) + ..add(ColorProperty('acrylicFallbackColor', acrylicFallbackColor)) ..add(ColorProperty('micaBackgroundColor', micaBackgroundColor)) ..add(ColorProperty('menuColor', menuColor)) ..add(ColorProperty('cardColor', cardColor)) diff --git a/test/acrylic_golden_test.dart b/test/acrylic_golden_test.dart new file mode 100644 index 000000000..ad0d7fb66 --- /dev/null +++ b/test/acrylic_golden_test.dart @@ -0,0 +1,116 @@ +@Tags(['golden']) +library; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('light default Acrylic', (tester) async { + await _pumpAcrylic(tester, theme: FluentThemeData.light()); + await expectLater( + find.byKey(_acrylicKey), + matchesGoldenFile('goldens/acrylic/light_default.png'), + ); + }); + + testWidgets('dark default Acrylic', (tester) async { + await _pumpAcrylic(tester, theme: FluentThemeData.dark()); + await expectLater( + find.byKey(_acrylicKey), + matchesGoldenFile('goldens/acrylic/dark_default.png'), + ); + }); + + testWidgets('colored backdrop through Acrylic', (tester) async { + await _pumpAcrylic( + tester, + theme: FluentThemeData.light(), + tint: Colors.blue, + tintAlpha: 0.8, + ); + await expectLater( + find.byKey(_acrylicKey), + matchesGoldenFile('goldens/acrylic/colored_backdrop.png'), + ); + }); + + testWidgets('high contrast content through Acrylic', (tester) async { + await _pumpAcrylic( + tester, + theme: FluentThemeData.dark(), + backdrop: const Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: Colors.white), + Align( + child: Text( + 'black and white', + style: TextStyle(color: Colors.black, fontSize: 24), + ), + ), + ], + ), + ); + await expectLater( + find.byKey(_acrylicKey), + matchesGoldenFile('goldens/acrylic/high_contrast.png'), + ); + }); + + testWidgets('disabled Acrylic fallback', (tester) async { + await _pumpAcrylic(tester, theme: FluentThemeData.dark(), disabled: true); + await expectLater( + find.byKey(_acrylicKey), + matchesGoldenFile('goldens/acrylic/disabled_fallback.png'), + ); + }); +} + +const _acrylicKey = ValueKey('acrylic-golden'); + +Future _pumpAcrylic( + WidgetTester tester, { + required FluentThemeData theme, + Color? tint, + double? tintAlpha, + Widget? backdrop, + bool disabled = false, +}) async { + final material = Acrylic( + tint: tint, + tintAlpha: tintAlpha, + child: const SizedBox.expand(), + ); + + await tester.pumpWidget( + FluentApp( + theme: theme, + home: Center( + child: RepaintBoundary( + key: _acrylicKey, + child: SizedBox( + width: 240, + height: 160, + child: Stack( + fit: StackFit.expand, + children: [ + backdrop ?? + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.red, Colors.blue, Colors.yellow], + ), + ), + ), + Positioned.fill( + child: disabled ? DisableAcrylic(child: material) : material, + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/acrylic_test.dart b/test/acrylic_test.dart new file mode 100644 index 000000000..fa4abff0f --- /dev/null +++ b/test/acrylic_test.dart @@ -0,0 +1,197 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AcrylicHelper.getTintOpacityModifier', () { + test('matches WinUI suppression for primary colors and neutrals', () { + expect( + AcrylicHelper.getTintOpacityModifier(Colors.white), + closeTo(0.45, 1e-6), + ); + expect( + AcrylicHelper.getTintOpacityModifier(Colors.black), + closeTo(0.85, 1e-6), + ); + expect( + AcrylicHelper.getTintOpacityModifier(const Color(0xFF808080)), + closeTo(0.8982, 0.001), + ); + expect( + AcrylicHelper.getTintOpacityModifier(Colors.red), + closeTo(0.9, 1e-6), + ); + expect( + AcrylicHelper.getTintOpacityModifier(Colors.blue), + closeTo(0.9, 1e-6), + ); + }); + + test('uses absolute HSV value deviation for intermediate neutrals', () { + expect( + AcrylicHelper.getTintOpacityModifier(const Color(0xFF404040)), + closeTo(0.875, 0.002), + ); + expect( + AcrylicHelper.getTintOpacityModifier(const Color(0xFFBFBFBF)), + closeTo(0.676, 0.002), + ); + }); + }); + + group('AcrylicHelper tint and luminosity', () { + test('includes tint opacity and only modifies automatic tint opacity', () { + final automatic = AcrylicHelper.getEffectiveTintColor(Colors.white, 0.5); + final explicit = AcrylicHelper.getEffectiveTintColor( + Colors.white, + 0.5, + luminosityOpacity: 0.8, + ); + + expect(automatic.a, closeTo(0.225, 1e-6)); + expect(explicit.a, closeTo(0.5, 1e-6)); + }); + + test('derives automatic luminosity from the original tint opacity', () { + final color = AcrylicHelper.getLuminosityColor( + const Color(0xFF808080), + null, + 0.5, + ); + + expect(color.r, closeTo(0.502, 0.002)); + expect(color.g, closeTo(0.502, 0.002)); + expect(color.b, closeTo(0.502, 0.002)); + expect(color.a, closeTo(0.59, 0.003)); + }); + + test('explicit luminosity opacity overrides automatic calculation', () { + final color = AcrylicHelper.getLuminosityColor( + const Color(0xFFFF0000), + 0.25, + 0.5, + ); + + expect(color.r, closeTo(1, 1e-6)); + expect(color.g, closeTo(0, 1e-6)); + expect(color.b, closeTo(0, 1e-6)); + expect(color.a, closeTo(0.25, 1e-6)); + }); + + test('clamps tint and luminosity alpha values', () { + final tint = AcrylicHelper.getEffectiveTintColor( + Colors.white, + 2, + luminosityOpacity: 2, + ); + final luminosity = AcrylicHelper.getLuminosityColor(Colors.white, -1, 2); + + expect(tint.a, closeTo(1, 1e-6)); + expect(luminosity.a, closeTo(0, 1e-6)); + }); + }); + + group('Acrylic widget', () { + testWidgets( + 'keeps automatic luminosity and resolves native theme resources', + (tester) async { + AcrylicProperties? properties; + await tester.pumpWidget( + FluentApp( + home: Acrylic( + child: Builder( + builder: (context) { + properties = AcrylicProperties.of(context); + return const SizedBox(width: 80, height: 40); + }, + ), + ), + ), + ); + + expect(properties?.luminosityAlpha, isNull); + expect(properties?.tint, const Color(0xFFFCFCFC)); + expect(properties?.tintAlpha, 0.0); + expect(properties?.fallbackColor, const Color(0xFFF9F9F9)); + expect(find.byType(BackdropFilter), findsOneWidget); + }, + ); + + testWidgets('disabled Acrylic is an opaque fallback without filtering', ( + tester, + ) async { + await tester.pumpWidget( + FluentApp( + home: DisableAcrylic( + child: Acrylic( + fallbackColor: Colors.green, + child: const SizedBox(width: 80, height: 40), + ), + ), + ), + ); + + expect(find.byType(BackdropFilter), findsNothing); + + expect(find.byType(ClipPath), findsOneWidget); + }); + + testWidgets('theme changes update Acrylic resources', (tester) async { + Color? fallbackColor; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FluentTheme( + data: FluentThemeData.light(), + child: Acrylic( + child: Builder( + builder: (context) { + fallbackColor = AcrylicProperties.of(context).fallbackColor; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + expect(fallbackColor, const Color(0xFFF9F9F9)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FluentTheme( + data: FluentThemeData.dark(), + child: Acrylic( + child: Builder( + builder: (context) { + fallbackColor = AcrylicProperties.of(context).fallbackColor; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + expect(fallbackColor, const Color(0xFF2C2C2C)); + }); + + testWidgets('AnimatedAcrylic retains a child above its material', ( + tester, + ) async { + await tester.pumpWidget( + const FluentApp( + home: AnimatedAcrylic( + duration: Duration(milliseconds: 100), + tintAlpha: 0, + child: Text('content above acrylic'), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('content above acrylic'), findsOneWidget); + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byType(CustomPaint), findsWidgets); + }); + }); +} diff --git a/test/autosuggestbox_test.dart b/test/autosuggestbox_test.dart index 738ee0e43..de092c686 100644 --- a/test/autosuggestbox_test.dart +++ b/test/autosuggestbox_test.dart @@ -87,6 +87,10 @@ void main() { find.byType(CompositedTransformFollower), ); expect(follower.offset.dy, lessThan(0)); + expect( + tester.widget(find.byType(Acrylic)).luminosityAlpha, + 0.85, + ); }, ); diff --git a/test/goldens/acrylic/colored_backdrop.png b/test/goldens/acrylic/colored_backdrop.png new file mode 100644 index 000000000..462c53eb2 Binary files /dev/null and b/test/goldens/acrylic/colored_backdrop.png differ diff --git a/test/goldens/acrylic/dark_default.png b/test/goldens/acrylic/dark_default.png new file mode 100644 index 000000000..d7f8a4276 Binary files /dev/null and b/test/goldens/acrylic/dark_default.png differ diff --git a/test/goldens/acrylic/disabled_fallback.png b/test/goldens/acrylic/disabled_fallback.png new file mode 100644 index 000000000..4b637e902 Binary files /dev/null and b/test/goldens/acrylic/disabled_fallback.png differ diff --git a/test/goldens/acrylic/high_contrast.png b/test/goldens/acrylic/high_contrast.png new file mode 100644 index 000000000..912da84ef Binary files /dev/null and b/test/goldens/acrylic/high_contrast.png differ diff --git a/test/goldens/acrylic/light_default.png b/test/goldens/acrylic/light_default.png new file mode 100644 index 000000000..c08af9ddd Binary files /dev/null and b/test/goldens/acrylic/light_default.png differ