nearly feature complete, bar dragging down

This commit is contained in:
Thomas Way
2026-02-05 22:02:33 +00:00
parent 53c5ff1cd7
commit 44284fd7d9
3 changed files with 248 additions and 176 deletions
@@ -37,77 +37,116 @@ class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: LayoutBuilder( body: StackedScrollView(
builder: (context, constraints) { bottomPeekHeight: 100,
final imageHeight = 200.0; // Your image's height topChild: Container(
final viewportHeight = constraints.maxHeight; color: Colors.blue,
child: const Center(
// Calculate padding to center the image in the viewport child: Text('Top', style: TextStyle(color: Colors.white, fontSize: 32)),
final topPadding = (viewportHeight - imageHeight) / 2; ),
final snapOffset = topPadding + (imageHeight * 2 / 3); ),
bottomChild: Container(
return SingleChildScrollView( height: 800,
controller: _scrollController, decoration: const BoxDecoration(
physics: SnapToPartialPhysics(snapOffset: snapOffset), color: Colors.white,
child: Column( borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
crossAxisAlignment: CrossAxisAlignment.stretch, boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
children: [ ),
SizedBox(height: topPadding), // Push image to center padding: const EdgeInsets.all(24),
Center(child: Image.asset('assets/immich-logo.png', height: imageHeight)), child: const Text('Bottom sheet content'),
Opacity( ),
opacity: _opacity,
child: Container(
constraints: BoxConstraints(minHeight: snapOffset + 100),
color: Colors.blue,
height: 100 + imageHeight * (1 / 3),
child: const Text('Some content'),
),
),
],
),
);
},
), ),
); );
} }
} }
class SnapToPartialPhysics extends ScrollPhysics { class StackedScrollView extends StatefulWidget {
final double snapOffset; final Widget topChild;
final Widget bottomChild;
final double bottomPeekHeight;
const SnapToPartialPhysics({super.parent, required this.snapOffset}); const StackedScrollView({super.key, required this.topChild, required this.bottomChild, this.bottomPeekHeight = 80});
@override @override
SnapToPartialPhysics applyTo(ScrollPhysics? ancestor) { State<StackedScrollView> createState() => _StackedScrollViewState();
return SnapToPartialPhysics(parent: buildParent(ancestor), snapOffset: snapOffset);
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
final tolerance = toleranceFor(position);
// If already at a snap point, let it settle naturally
if ((position.pixels - 0).abs() < tolerance.distance || (position.pixels - snapOffset).abs() < tolerance.distance) {
return super.createBallisticSimulation(position, velocity);
}
// Determine snap target based on position and velocity
double target;
if (velocity > 0) {
// Scrolling down
target = snapOffset;
} else if (velocity < 0) {
// Scrolling up
target = 0;
} else {
// No velocity, snap to nearest
target = position.pixels < snapOffset / 2 ? 0 : snapOffset;
}
return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance);
}
} }
class _StackedScrollViewState extends State<StackedScrollView> with SingleTickerProviderStateMixin {
double _offset = 0;
late double _maxOffset;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController.unbounded(vsync: this)..addListener(_onAnimate);
}
void _onAnimate() {
final clamped = _controller.value.clamp(0.0, _maxOffset);
if (clamped != _offset) {
setState(() => _offset = clamped);
}
// Stop the controller if we've hit a boundary
if (_controller.value <= 0 || _controller.value >= _maxOffset) {
_controller.stop();
}
}
void _onDragUpdate(DragUpdateDetails details) {
_controller.stop();
setState(() {
_offset = (_offset - details.delta.dy).clamp(0.0, _maxOffset);
});
}
void _onDragEnd(DragEndDetails details) {
final velocity = -(details.primaryVelocity ?? 0);
final simulation = ClampingScrollSimulation(position: _offset, velocity: velocity);
_controller.animateWith(simulation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final viewportHeight = constraints.maxHeight;
_maxOffset = viewportHeight - widget.bottomPeekHeight;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _onDragUpdate,
onVerticalDragEnd: _onDragEnd,
child: ClipRect(
child: SizedBox(
height: viewportHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
// Top child — fills the screen, scrolls up
Positioned(top: -_offset, left: 0, right: 0, height: viewportHeight, child: widget.topChild),
// Bottom child — overlaps, peeks from bottom
Positioned(
top: viewportHeight - widget.bottomPeekHeight - _offset,
left: 0,
right: 0,
child: widget.bottomChild,
),
],
),
),
),
);
},
);
}
}
// class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> { // class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
// final ScrollController _scrollController = ScrollController(); // final ScrollController _scrollController = ScrollController();
// double _opacity = 0.0; // double _opacity = 0.0;
@@ -224,86 +224,85 @@ class AssetDetails extends ConsumerWidget {
return Container( return Container(
constraints: BoxConstraints(minHeight: minHeight), constraints: BoxConstraints(minHeight: minHeight),
decoration: BoxDecoration(color: context.isDarkTheme ? context.colorScheme.surface : Colors.white), decoration: BoxDecoration(
child: Stack( color: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [ children: [
const _DragHandle(), const _DragHandle(),
Column( // Asset Date and Time
children: [ SheetTile(
// Asset Date and Time title: _getDateTime(context, asset, exifInfo),
SheetTile( titleStyle: context.textTheme.labelLarge,
title: _getDateTime(context, asset, exifInfo), trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
titleStyle: context.textTheme.labelLarge, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
// File info
buildFileInfoTile(),
// Camera info
if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Lens info
if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 60),
],
), ),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
// File info
buildFileInfoTile(),
// Camera info
if (cameraTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Lens info
if (lensTitle != null) ...[
const SizedBox(height: 16),
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 60),
], ],
), ),
); );
@@ -408,14 +407,9 @@ class _DragHandle extends StatelessWidget {
label: MaterialLocalizations.of(context).modalBarrierDismissLabel, label: MaterialLocalizations.of(context).modalBarrierDismissLabel,
container: true, container: true,
button: true, button: true,
child: Container( child: SizedBox(
decoration: BoxDecoration(
color: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
width: double.infinity, width: double.infinity,
height: math.max(handleSize.height, kMinInteractiveDimension), height: math.max(handleSize.height, kMinInteractiveDimension),
transform: Matrix4.translationValues(0, -kMinInteractiveDimension, 0),
child: Center( child: Center(
child: Container( child: Container(
height: handleSize.height, height: handleSize.height,
@@ -111,7 +111,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final int heroOffset; late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown; bool? hasDraggedDown;
bool isSnapping = false;
bool blockGestures = false; bool blockGestures = false;
bool dragInProgress = false; bool dragInProgress = false;
bool shouldPopOnDrag = false; bool shouldPopOnDrag = false;
@@ -133,7 +132,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
double _assetDetailsOpacity = 0.0; double _assetDetailsOpacity = 0.0;
final _assetDetailsSnap = 5; // final _assetDetailsSnap = 5;
@override @override
void initState() { void initState() {
@@ -278,12 +277,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
viewController = controller; viewController = controller;
} }
bool onScrollNotification(ScrollNotification notification) {
if (notification is ScrollStartNotification) {
// Drag started
print('Scroll/drag started');
// final dragDetails = notification.dragDetails;
// _onDragStart(_, notification.dragDetails, controller, scaleStateController)
} else if (notification is ScrollUpdateNotification) {
// Drag is ongoing
print('Scroll offset: ${notification.metrics.pixels}');
} else if (notification is ScrollEndNotification) {
// Drag ended
print('Scroll/drag ended');
}
return false; // return false to allow the notification to continue propagating
}
void _onDragStart( void _onDragStart(
_, _,
DragStartDetails details, DragStartDetails details,
PhotoViewControllerBase controller, PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController, PhotoViewScaleStateController scaleStateController,
) { ) {
print("photoview drag start");
viewController = controller; viewController = controller;
dragDownPosition = details.localPosition; dragDownPosition = details.localPosition;
initialPhotoViewState = controller.value; initialPhotoViewState = controller.value;
@@ -560,6 +576,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
} }
}); });
// debugPaintSizeEnabled = true;
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
// Issue: https://github.com/flutter/flutter/issues/109037 // Issue: https://github.com/flutter/flutter/issues/109037
// TODO: Add a custom scrum builder once the fix lands on stable // TODO: Add a custom scrum builder once the fix lands on stable
@@ -599,41 +617,54 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Calculate padding to center the image in the viewport // Calculate padding to center the image in the viewport
final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0); final topPadding = math.max((viewportHeight - imageHeight) / 2, 0.0);
final snapOffset = math.min(topPadding + (imageHeight / 2), (viewportHeight / 3) * 2); final snapOffset = math.max(topPadding + (imageHeight / 2), viewportHeight / 3 * 2);
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
SingleChildScrollView( NotificationListener<ScrollNotification>(
controller: _scrollController, onNotification: onScrollNotification,
physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset), child: SingleChildScrollView(
child: Column( controller: _scrollController,
crossAxisAlignment: CrossAxisAlignment.stretch, physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset),
children: [ child: Column(
SizedBox(height: topPadding), crossAxisAlignment: CrossAxisAlignment.stretch,
SizedBox( children: [
height: imageHeight, // Container(
child: PhotoViewGallery.builder( // height: topPadding,
gaplessPlayback: true, // decoration: const BoxDecoration(color: Colors.green),
loadingBuilder: _placeholderBuilder, // ),
pageController: pageController, SizedOverflowBox(
scrollPhysics: CurrentPlatform.isIOS size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)),
? const FastScrollPhysics() // Use bouncing physics for iOS alignment: Alignment.topCenter,
: const FastClampingScrollPhysics(), // Use heavy physics for Android child: SizedBox(
itemCount: totalAssets, height: viewportHeight,
onPageChanged: _onPageChanged, child: PhotoViewGallery.builder(
scaleStateChangedCallback: _onScaleStateChanged, gaplessPlayback: true,
builder: _assetBuilder, loadingBuilder: _placeholderBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor), pageController: pageController,
enablePanAlways: true, scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
),
), ),
),
AnimatedOpacity( // TODO: if zooming, this should be hidden, and we should
opacity: _assetDetailsOpacity, // probably disable the scroll physics
duration: kThemeAnimationDuration, AnimatedOpacity(
child: AssetDetails(minHeight: viewportHeight / 3 * 2), opacity: _assetDetailsOpacity,
), duration: kThemeAnimationDuration,
], child: AssetDetails(minHeight: snapOffset),
),
],
),
), ),
), ),
Positioned( Positioned(
@@ -679,6 +710,14 @@ class VariableHeightSnappingPhysics extends ScrollPhysics {
); );
} }
@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
if (value < position.pixels && position.pixels <= position.minScrollExtent) {
return 0.0;
}
return super.applyBoundaryConditions(position, value);
}
@override @override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
final tolerance = toleranceFor(position); final tolerance = toleranceFor(position);