From 32febb8dd567b3893cb07253d57b36f2bfeeeca8 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 15 Oct 2026 08:30:17 -0400 Subject: [PATCH] Refine iOS search glass and use standard modal motion Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- .../ios/Runner/ConcentricSheetSurface.swift | 2 +- mobile/lib/features/home/home_page.dart | 12 ++++ mobile/lib/features/search/search_page.dart | 9 ++- .../lib/shared/widgets/filter_chip_bar.dart | 60 +++++++++++++++++++ mobile/test/features/home/home_page_test.dart | 4 +- .../features/search/search_page_test.dart | 23 ++++++- 6 files changed, 103 insertions(+), 7 deletions(-) diff --git a/mobile/ios/Runner/ConcentricSheetSurface.swift b/mobile/ios/Runner/ConcentricSheetSurface.swift index 71f0d8ebb7..f959c093d7 100644 --- a/mobile/ios/Runner/ConcentricSheetSurface.swift +++ b/mobile/ios/Runner/ConcentricSheetSurface.swift @@ -154,7 +154,7 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { glassView.effect = effect } glassView.backgroundColor = .clear - glassView.contentView.backgroundColor = surfaceColor.withAlphaComponent(0.12) + glassView.contentView.backgroundColor = surfaceColor.withAlphaComponent(min(surfaceColor.cgColor.alpha, 0.12)) } else { surfaceView.backgroundColor = surfaceColor } diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 0fa21c0289..cfc7f62942 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -110,13 +111,24 @@ class HomePage extends HookConsumerWidget { allowSnapshotting: false, transitionDuration: reducedMotion ? Duration.zero + : defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoRouteTransitionMixin.kTransitionDuration : const Duration(milliseconds: 260), reverseTransitionDuration: reducedMotion ? Duration.zero + : defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoRouteTransitionMixin.kTransitionDuration : const Duration(milliseconds: 220), transitionsBuilder: (_, animation, secondaryAnimation, child) => reducedMotion ? child + : defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoFullscreenDialogTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: false, + child: child, + ) : SlideTransition( position: animation.drive( Tween( diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 442f7680f4..8ea149dcb5 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -86,7 +86,13 @@ class SearchPage extends HookConsumerWidget { final searchPrimaryColor = navigationPrimaryForeground(context); final searchPlaceholderColor = navigationSecondaryForeground(context); final fieldHeight = _idleSearchFieldHeight(context); - final filtersHeight = _searchHeaderFiltersHeight(context); + final filtersHeight = nativeIos + ? (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16).clamp( + 48.0, + double.infinity, + ) + + Grid.xxs * 2 + : _searchHeaderFiltersHeight(context); void activateSearch() => focusNode.requestFocus(); @@ -208,6 +214,7 @@ class SearchPage extends HookConsumerWidget { key: const ValueKey('search-header-filters'), height: filtersHeight, child: FilterChipBar<_SearchFilter>( + useNativeGlass: nativeIos, expandItems: true, visualDensity: const VisualDensity(horizontal: -2), chipVerticalPadding: _searchFilterChipVerticalPadding, diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 628267c167..0b4f622c59 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'ios_glass_navigation_button.dart'; import '../theme/theme.dart'; @@ -37,6 +39,9 @@ class FilterChipBar extends StatelessWidget { /// When enabled, every chip shares the width rather than scrolling. final bool expandItems; + /// Uses native glass capsules on iOS for navigation-style filters. + final bool useNativeGlass; + /// Material density applied to each chip. final VisualDensity visualDensity; @@ -52,6 +57,7 @@ class FilterChipBar extends StatelessWidget { required this.selected, required this.onSelected, this.expandItems = false, + this.useNativeGlass = false, this.visualDensity = VisualDensity.compact, this.chipVerticalPadding = Grid.quarter, this.barVerticalPadding = Grid.xxs, @@ -59,6 +65,60 @@ class FilterChipBar extends StatelessWidget { @override Widget build(BuildContext context) { + if (useNativeGlass && defaultTargetPlatform == TargetPlatform.iOS) { + final textStyle = filterChipTextStyle.copyWith( + fontWeight: FontWeight.w600, + ); + final height = (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) + .clamp(48.0, double.infinity); + final widths = [ + for (final item in items) + (TextPainter( + text: TextSpan(text: item.label, style: textStyle), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout()).width + + 32, + ]; + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxWidth - Grid.gutter * 2; + final required = + widths.fold(0.0, (sum, width) => sum + width) + + Grid.xxs * (items.length - 1); + final extra = expandItems && available > required + ? (available - required) / items.length + : 0.0; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: barVerticalPadding, + ), + child: Row( + children: [ + for (var index = 0; index < items.length; index++) ...[ + if (index > 0) const SizedBox(width: Grid.xxs), + IosGlassNavigationButton( + icon: IosGlassNavigationIcon.more, + label: items[index].label, + semanticLabel: items[index].label, + onPressed: () => onSelected(items[index].id), + isSelected: selected == items[index].id, + width: widths[index] + extra, + height: height, + controlSize: height - 8, + fillWidth: true, + foregroundColor: context.colors.primary, + ), + ], + ], + ), + ); + }, + ); + } final chips = [ for (var index = 0; index < items.length; index++) ...[ if (index > 0) const SizedBox(width: Grid.xxs), diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 7dff6f00ff..1b6db41d0b 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -148,10 +148,10 @@ void main() { final searchRoute = ModalRoute.of(tester.element(find.byType(SearchPage)))!; expect(searchRoute.allowSnapshotting, isFalse); expect(searchRoute.opaque, isTrue); - expect(searchRoute.transitionDuration, const Duration(milliseconds: 260)); + expect(searchRoute.transitionDuration, const Duration(milliseconds: 500)); expect( searchRoute.reverseTransitionDuration, - const Duration(milliseconds: 220), + const Duration(milliseconds: 500), ); Navigator.of(tester.element(find.byType(SearchPage))).pop(); await tester.pumpAndSettle(); diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 55abc3d113..6f96873249 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -63,7 +63,12 @@ void main() { expect(tester.getRect(close).left, greaterThan(fieldRect.right)); expect(tester.getSize(close), const Size(48, 48)); for (final label in ['All', 'Messages', 'Channels', 'People']) { - expect(find.text(label).hitTestable(), findsOneWidget); + expect( + platform == TargetPlatform.iOS + ? find.byTooltip(label) + : find.text(label).hitTestable(), + findsOneWidget, + ); } final input = tester.element(find.byType(TextField)); await tester.tap(find.byType(TextField)); @@ -76,7 +81,19 @@ void main() { isTrue, ); await tester.enterText(find.byType(TextField), 'design'); - await tester.tap(find.text('Channels')); + if (platform == TargetPlatform.iOS) { + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is IosGlassNavigationButton && + widget.label == 'Channels', + ), + ) + .onPressed!(); + } else { + await tester.tap(find.text('Channels')); + } await tester.pumpAndSettle(); expect(tester.getRect(field), fieldRect); expect(tester.getRect(filters), filtersRect); @@ -90,7 +107,7 @@ void main() { ); expect( find.byType(IosGlassNavigationButton), - platform == TargetPlatform.iOS ? findsOneWidget : findsNothing, + platform == TargetPlatform.iOS ? findsNWidgets(5) : findsNothing, ); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink());