commit 63ff2b6f9ea24a762c3340b50586b3c98443de8f
parent 073feccbbfc02231b49df39efdd8849b15f2a988
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Mon, 7 Jul 2025 11:03:50 -0700
ui: Stabilize ImageCarousel height when swiping between images
This commit enhances the ImageCarousel component to maintain a consistent
height when navigating between images of different aspect ratios. The
changes prevent the UI from "jumping" during carousel navigation, which
improves the overall user experience.
Key improvements:
- Added `first_image_fill` property to store dimensions of the first image
- Modified height calculation to prioritize the first image's dimensions
- Refactored image fill calculation into a reusable `compute_item_fill` method
- Added proper view clipping to prevent content overflow
- Simplified filling behavior for more predictable layout
These changes provide a smoother, more stable carousel experience by
maintaining consistent dimensions throughout image navigation.
Changelog-Changed: Improved the image sizing behavior on the image carousel for a smoother experience
Closes: https://github.com/damus-io/damus/issues/2724
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Diffstat:
1 file changed, 45 insertions(+), 9 deletions(-)
diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift
@@ -162,6 +162,7 @@ class CarouselModel: ObservableObject {
// Upon updating information, update the carousel fill size if the size for the current url has changed
if oldValue[current_url] != media_size_information[current_url] {
self.refresh_current_item_fill()
+ self.refresh_first_item_height()
}
}
}
@@ -186,6 +187,13 @@ class CarouselModel: ObservableObject {
/// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill?
+ /// Holds the ideal fill dimensions for the first item in the carousel.
+ /// This is used to maintain a consistent height for the carousel when swiping between images.
+ /// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
+ /// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
+ /// preventing the UI from "jumping" when swiping between images of different aspect ratios.
+ @Published private(set) var first_image_fill: ImageFill?
+
// MARK: Initialization and de-initialization
@@ -207,6 +215,7 @@ class CarouselModel: ObservableObject {
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
+ self.refresh_first_item_height()
}
}
@@ -241,10 +250,17 @@ class CarouselModel: ObservableObject {
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
private func refresh_current_item_fill() {
- if let current_url,
- let item_size = self.media_size_information[current_url],
+ self.current_item_fill = self.compute_item_fill(url: current_url)
+ }
+
+ /// Computes the image fill properties for a given URL without side effects.
+ /// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
+ /// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
+ private func compute_item_fill(url: URL?) -> ImageFill? {
+ if let url,
+ let item_size = self.media_size_information[url],
let geo_size {
- self.current_item_fill = ImageFill.calculate_image_fill(
+ return ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
@@ -252,9 +268,26 @@ class CarouselModel: ObservableObject {
)
}
else {
- self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
+ return nil // Not enough information to compute the proper fill. Default to nil
}
}
+
+ /// This function refreshes the first item height based on the current state of the model
+ /// **Usage note:** This is private, do not call this directly from outside the class.
+ /// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
+ /// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
+ private func refresh_first_item_height() {
+ self.first_image_fill = self.compute_first_item_fill()
+ }
+
+ /// Computes the first item fill with no side-effects.
+ /// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
+ /// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
+ /// to establish a consistent height for the entire carousel.
+ private func compute_first_item_fill() -> ImageFill? {
+ guard let first_url = urls[safe: 0] else { return nil }
+ return self.compute_item_fill(url: first_url.url)
+ }
}
// MARK: - Image Carousel
@@ -286,13 +319,15 @@ struct ImageCarousel<Content: View>: View {
self.content = content
}
- var filling: Bool {
- model.current_item_fill?.filling == true
- }
+ /// Determines if the image should fill its container.
+ /// Always returns true to ensure images consistently fill the width of the container.
+ /// This simplifies the layout behavior and prevents inconsistent sizing between carousel items.
+ var filling: Bool { true }
var height: CGFloat {
- // Use the calculated fill height if available, otherwise use the default fill height
- model.current_item_fill?.height ?? model.default_fill_height
+ // Use the first image height (to prevent height from jumping when swiping), then default to the default fill height
+ // This prioritization ensures consistent carousel height regardless of which image is currently displayed
+ model.first_image_fill?.height ?? model.default_fill_height
}
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -376,6 +411,7 @@ struct ImageCarousel<Content: View>: View {
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(height: height)
+ .clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
.onChange(of: model.selectedIndex) { value in
model.selectedIndex = value
}