From 2d6580acd89885c964bd0fd03ab573735d47e673 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:15:42 +0530 Subject: [PATCH] feat(mobile): dynamic layout in new timeline (#23837) * feat(mobile): dynamic layout in new timeline * simplify _buildAssetRow * auto dynamic mode on smaller column count * auto layout on smaller tiles --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../widgets/timeline/fixed/row.dart | 89 +++++++++++++------ .../widgets/timeline/fixed/segment.model.dart | 80 +++++++++++++---- .../widgets/timeline/segment_builder.dart | 2 +- .../asset_list_layout_settings.dart | 12 +-- 4 files changed, 134 insertions(+), 49 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/fixed/row.dart b/mobile/lib/presentation/widgets/timeline/fixed/row.dart index 3fe3cea3c9..97067add24 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/row.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/row.dart @@ -1,27 +1,45 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -class FixedTimelineRow extends MultiChildRenderObjectWidget { - final double dimension; +class TimelineRow extends MultiChildRenderObjectWidget { + final double height; + final List widths; final double spacing; final TextDirection textDirection; - const FixedTimelineRow({ + const TimelineRow({ super.key, - required this.dimension, + required this.height, + required this.widths, required this.spacing, required this.textDirection, required super.children, }); + factory TimelineRow.fixed({ + required double dimension, + required double spacing, + required TextDirection textDirection, + required List children, + }) => TimelineRow( + height: dimension, + widths: List.filled(children.length, dimension), + spacing: spacing, + textDirection: textDirection, + children: children, + ); + @override RenderObject createRenderObject(BuildContext context) { - return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection); + return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection); } @override void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { - renderObject.dimension = dimension; + renderObject.height = height; + renderObject.widths = widths; renderObject.spacing = spacing; renderObject.textDirection = textDirection; } @@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('height', height)); + properties.add(DiagnosticsProperty>('widths', widths)); properties.add(DoubleProperty('spacing', spacing)); properties.add(EnumProperty('textDirection', textDirection)); } @@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox RenderBoxContainerDefaultsMixin { RenderFixedRow({ List? children, - required double dimension, + required double height, + required List widths, required double spacing, required TextDirection textDirection, - }) : _dimension = dimension, + }) : _height = height, + _widths = widths, _spacing = spacing, _textDirection = textDirection { addAll(children); } - double get dimension => _dimension; - double _dimension; + double get height => _height; + double _height; - set dimension(double value) { - if (_dimension == value) return; - _dimension = value; + set height(double value) { + if (_height == value) return; + _height = value; + markNeedsLayout(); + } + + List get widths => _widths; + List _widths; + + set widths(List value) { + if (listEquals(_widths, value)) return; + _widths = value; markNeedsLayout(); } @@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox } } - double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1); + double get intrinsicWidth => widths.sum + (spacing * (childCount - 1)); @override double computeMinIntrinsicWidth(double height) => intrinsicWidth; @@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox double computeMaxIntrinsicWidth(double height) => intrinsicWidth; @override - double computeMinIntrinsicHeight(double width) => dimension; + double computeMinIntrinsicHeight(double width) => height; @override - double computeMaxIntrinsicHeight(double width) => dimension; + double computeMaxIntrinsicHeight(double width) => height; @override double? computeDistanceToActualBaseline(TextBaseline baseline) { @@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('height', height)); + properties.add(DiagnosticsProperty>('widths', widths)); properties.add(DoubleProperty('spacing', spacing)); properties.add(EnumProperty('textDirection', textDirection)); } @@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox return; } // Use the entire width of the parent for the row. - size = Size(constraints.maxWidth, dimension); - // Each tile is forced to be dimension x dimension. - final childConstraints = BoxConstraints.tight(Size(dimension, dimension)); + size = Size(constraints.maxWidth, height); + final flipMainAxis = textDirection == TextDirection.rtl; - Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); - final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); + int childIndex = 0; + double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0; // Layout each child horizontally. - while (child != null) { + while (child != null && childIndex < widths.length) { + final width = widths[childIndex]; + final childConstraints = BoxConstraints.tight(Size(width, height)); child.layout(childConstraints, parentUsesSize: false); final childParentData = child.parentData! as _RowParentData; - childParentData.offset = offset; - offset += Offset(dx, 0); + childParentData.offset = Offset(currentX, 0); child = childParentData.nextSibling; + childIndex++; + + if (child != null && childIndex < widths.length) { + final nextWidth = widths[childIndex]; + currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing; + } } } } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index b879b33f68..aa2112b8dd 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; @@ -78,6 +80,7 @@ class FixedSegment extends Segment { assetCount: numberOfAssets, tileHeight: tileHeight, spacing: spacing, + columnCount: columnCount, ); } } @@ -87,24 +90,32 @@ class _FixedSegmentRow extends ConsumerWidget { final int assetCount; final double tileHeight; final double spacing; + final int columnCount; const _FixedSegmentRow({ required this.assetIndex, required this.assetCount, required this.tileHeight, required this.spacing, + required this.columnCount, }); @override Widget build(BuildContext context, WidgetRef ref) { final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final timelineService = ref.read(timelineServiceProvider); + final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); if (isScrubbing) { return _buildPlaceholder(context); } if (timelineService.hasRange(assetIndex, assetCount)) { - return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); + return _buildAssetRow( + context, + timelineService.getAssets(assetIndex, assetCount), + timelineService, + isDynamicLayout, + ); } return FutureBuilder>( @@ -113,7 +124,7 @@ class _FixedSegmentRow extends ConsumerWidget { if (snapshot.connectionState != ConnectionState.done) { return _buildPlaceholder(context); } - return _buildAssetRow(context, snapshot.requireData, timelineService); + return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout); }, ); } @@ -122,23 +133,58 @@ class _FixedSegmentRow extends ConsumerWidget { return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing); } - Widget _buildAssetRow(BuildContext context, List assets, TimelineService timelineService) { - return FixedTimelineRow( - dimension: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: [ - for (int i = 0; i < assets.length; i++) - TimelineAssetIndexWrapper( + Widget _buildAssetRow( + BuildContext context, + List assets, + TimelineService timelineService, + bool isDynamicLayout, + ) { + final children = [ + for (int i = 0; i < assets.length; i++) + TimelineAssetIndexWrapper( + assetIndex: assetIndex + i, + segmentIndex: 0, // For simplicity, using 0 for now + child: _AssetTileWidget( + key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), + asset: assets[i], assetIndex: assetIndex + i, - segmentIndex: 0, // For simplicity, using 0 for now - child: _AssetTileWidget( - key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), - asset: assets[i], - assetIndex: assetIndex + i, - ), ), - ], + ), + ]; + + final widths = List.filled(assets.length, tileHeight); + + if (isDynamicLayout) { + final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); + final meanAspectRatio = aspectRatios.sum / assets.length; + + // 1: mean width + // 0.5: width < mean - threshold + // 1.5: width > mean + threshold + final arConfiguration = aspectRatios.map((e) { + if (e - meanAspectRatio > 0.3) return 1.5; + if (e - meanAspectRatio < -0.3) return 0.5; + return 1.0; + }); + + // Normalize to get width distribution + final sum = arConfiguration.sum; + + int index = 0; + for (final ratio in arConfiguration) { + // Distribute the available width proportionally based on aspect ratio configuration + widths[index++] = ((ratio * assets.length) / sum) * tileHeight; + } + } + + return TimelineDragRegion( + child: TimelineRow( + height: tileHeight, + widths: widths, + spacing: spacing, + textDirection: Directionality.of(context), + children: children, + ), ); } } diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart index 79ffb47e95..442d42d536 100644 --- a/mobile/lib/presentation/widgets/timeline/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -24,7 +24,7 @@ abstract class SegmentBuilder { Size size = kTimelineFixedTileExtent, double spacing = kTimelineSpacing, }) => RepaintBoundary( - child: FixedTimelineRow( + child: TimelineRow.fixed( dimension: size.height, spacing: spacing, textDirection: Directionality.of(context), diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index 5d82630fc6..2d5c9f06eb 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -24,11 +25,12 @@ class LayoutSettings extends HookConsumerWidget { title: "asset_list_layout_sub_title".t(context: context), icon: Icons.view_module_outlined, ), - SettingsSwitchListTile( - valueNotifier: useDynamicLayout, - title: "asset_list_layout_settings_dynamic_layout_title".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), - ), + if (!Store.isBetaTimelineEnabled) + SettingsSwitchListTile( + valueNotifier: useDynamicLayout, + title: "asset_list_layout_settings_dynamic_layout_title".t(context: context), + onChanged: (_) => ref.invalidate(appSettingsServiceProvider), + ), SettingsSliderListTile( valueNotifier: tilesPerRow, text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),