mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 16:09:29 +03:00
nearly feature complete, bar dragging down
This commit is contained in:
@@ -37,77 +37,116 @@ class _ImmichUIShowcasePageState extends State<ImmichUIShowcasePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final imageHeight = 200.0; // Your image's height
|
||||
final viewportHeight = constraints.maxHeight;
|
||||
|
||||
// Calculate padding to center the image in the viewport
|
||||
final topPadding = (viewportHeight - imageHeight) / 2;
|
||||
final snapOffset = topPadding + (imageHeight * 2 / 3);
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: SnapToPartialPhysics(snapOffset: snapOffset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: topPadding), // Push image to center
|
||||
Center(child: Image.asset('assets/immich-logo.png', height: imageHeight)),
|
||||
Opacity(
|
||||
opacity: _opacity,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: snapOffset + 100),
|
||||
color: Colors.blue,
|
||||
height: 100 + imageHeight * (1 / 3),
|
||||
child: const Text('Some content'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
body: StackedScrollView(
|
||||
bottomPeekHeight: 100,
|
||||
topChild: Container(
|
||||
color: Colors.blue,
|
||||
child: const Center(
|
||||
child: Text('Top', style: TextStyle(color: Colors.white, fontSize: 32)),
|
||||
),
|
||||
),
|
||||
bottomChild: Container(
|
||||
height: 800,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10, offset: Offset(0, -2))],
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: const Text('Bottom sheet content'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SnapToPartialPhysics extends ScrollPhysics {
|
||||
final double snapOffset;
|
||||
class StackedScrollView extends StatefulWidget {
|
||||
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
|
||||
SnapToPartialPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
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);
|
||||
}
|
||||
State<StackedScrollView> createState() => _StackedScrollViewState();
|
||||
}
|
||||
|
||||
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> {
|
||||
// final ScrollController _scrollController = ScrollController();
|
||||
// double _opacity = 0.0;
|
||||
|
||||
@@ -224,86 +224,85 @@ class AssetDetails extends ConsumerWidget {
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
decoration: BoxDecoration(color: context.isDarkTheme ? context.colorScheme.surface : Colors.white),
|
||||
child: Stack(
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const _DragHandle(),
|
||||
Column(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
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),
|
||||
],
|
||||
// Asset Date and Time
|
||||
SheetTile(
|
||||
title: _getDateTime(context, asset, exifInfo),
|
||||
titleStyle: context.textTheme.labelLarge,
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -408,14 +407,9 @@ class _DragHandle extends StatelessWidget {
|
||||
label: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
container: true,
|
||||
button: true,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: math.max(handleSize.height, kMinInteractiveDimension),
|
||||
transform: Matrix4.translationValues(0, -kMinInteractiveDimension, 0),
|
||||
child: Center(
|
||||
child: Container(
|
||||
height: handleSize.height,
|
||||
|
||||
@@ -111,7 +111,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
late final int heroOffset;
|
||||
late PhotoViewControllerValue initialPhotoViewState;
|
||||
bool? hasDraggedDown;
|
||||
bool isSnapping = false;
|
||||
bool blockGestures = false;
|
||||
bool dragInProgress = false;
|
||||
bool shouldPopOnDrag = false;
|
||||
@@ -133,7 +132,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
double _assetDetailsOpacity = 0.0;
|
||||
|
||||
final _assetDetailsSnap = 5;
|
||||
// final _assetDetailsSnap = 5;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -278,12 +277,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
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(
|
||||
_,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerBase controller,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
) {
|
||||
print("photoview drag start");
|
||||
viewController = controller;
|
||||
dragDownPosition = details.localPosition;
|
||||
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.
|
||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||
// 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
|
||||
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(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: topPadding),
|
||||
SizedBox(
|
||||
height: imageHeight,
|
||||
child: PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
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,
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: onScrollNotification,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: VariableHeightSnappingPhysics(snapStart: 0, snapEnd: snapOffset, snapOffset: snapOffset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Container(
|
||||
// height: topPadding,
|
||||
// decoration: const BoxDecoration(color: Colors.green),
|
||||
// ),
|
||||
SizedOverflowBox(
|
||||
size: Size(double.infinity, topPadding + imageHeight - (kMinInteractiveDimension / 2)),
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: viewportHeight,
|
||||
child: PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
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(
|
||||
opacity: _assetDetailsOpacity,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: AssetDetails(minHeight: viewportHeight / 3 * 2),
|
||||
),
|
||||
],
|
||||
|
||||
// TODO: if zooming, this should be hidden, and we should
|
||||
// probably disable the scroll physics
|
||||
AnimatedOpacity(
|
||||
opacity: _assetDetailsOpacity,
|
||||
duration: kThemeAnimationDuration,
|
||||
child: AssetDetails(minHeight: snapOffset),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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
|
||||
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
|
||||
final tolerance = toleranceFor(position);
|
||||
|
||||
Reference in New Issue
Block a user