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>
476 lines
20 KiB
Swift
476 lines
20 KiB
Swift
//
|
|
// ImageCarousel.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2022-10-16.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Kingfisher
|
|
import Combine
|
|
|
|
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void
|
|
|
|
let activityItems: [URL?]
|
|
let callback: Callback? = nil
|
|
let applicationActivities: [UIActivity]? = nil
|
|
let excludedActivityTypes: [UIActivity.ActivityType]? = nil
|
|
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
let controller = UIActivityViewController(
|
|
activityItems: activityItems as [Any],
|
|
applicationActivities: applicationActivities)
|
|
controller.excludedActivityTypes = excludedActivityTypes
|
|
controller.completionWithItemsHandler = callback
|
|
return controller
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
|
|
// nothing to do here
|
|
}
|
|
}
|
|
|
|
// Custom UIPageControl
|
|
struct PageControlView: UIViewRepresentable {
|
|
@Binding var currentPage: Int
|
|
var numberOfPages: Int
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeUIView(context: Context) -> UIPageControl {
|
|
let uiView = UIPageControl()
|
|
uiView.backgroundStyle = .minimal
|
|
uiView.currentPageIndicatorTintColor = UIColor(Color("DamusPurple"))
|
|
uiView.pageIndicatorTintColor = UIColor(Color("DamusLightGrey"))
|
|
uiView.currentPage = currentPage
|
|
uiView.numberOfPages = numberOfPages
|
|
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
|
|
return uiView
|
|
}
|
|
|
|
func updateUIView(_ uiView: UIPageControl, context: Context) {
|
|
uiView.currentPage = currentPage
|
|
uiView.numberOfPages = numberOfPages
|
|
}
|
|
}
|
|
|
|
extension PageControlView {
|
|
final class Coordinator: NSObject {
|
|
var parent: PageControlView
|
|
|
|
init(_ parent: PageControlView) {
|
|
self.parent = parent
|
|
}
|
|
|
|
@objc func valueChanged(sender: UIPageControl) {
|
|
let currentPage = sender.currentPage
|
|
withAnimation {
|
|
parent.currentPage = currentPage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
enum ImageShape {
|
|
case square
|
|
case landscape
|
|
case portrait
|
|
case unknown
|
|
|
|
static func determine_image_shape(_ size: CGSize) -> ImageShape {
|
|
guard size.height > 0 else {
|
|
return .unknown
|
|
}
|
|
let imageRatio = size.width / size.height
|
|
switch imageRatio {
|
|
case 1.0: return .square
|
|
case ..<1.0: return .portrait
|
|
case 1.0...: return .landscape
|
|
default: return .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size
|
|
///
|
|
/// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items,
|
|
/// and the ideal display size at each moment is not a trivial task.
|
|
///
|
|
/// The rules for the media fill are as follows:
|
|
/// 1. The media item should generally have a width that completely fills the width of its parent view
|
|
/// 2. The height of the carousel should be adjusted accordingly
|
|
/// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device
|
|
/// 4. If none of the above can be computed (e.g. due to missing information), default to a reasonable height, where the media item will fit into.
|
|
///
|
|
/// ## Usage notes
|
|
///
|
|
/// The view is has the following state management responsibilities:
|
|
/// 1. Watching the size of the images (via the `.observe_image_size` modifier)
|
|
/// 2. Notifying this class of geometry reader changes, by setting `geo_size`
|
|
///
|
|
/// ## Implementation notes
|
|
///
|
|
/// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause.
|
|
///
|
|
/// This is accomplished through the following pattern:
|
|
/// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view
|
|
/// 2. However, `current_item_fill` has a mathematical dependency on other members of this class
|
|
/// 3. Therefore, the members on which the fill property depends on all have `didSet` observers that will cause the `current_item_fill` to be recalculated and published.
|
|
///
|
|
/// This pattern helps ensure that the state is always consistent and that the view is always up-to-date.
|
|
///
|
|
/// This class is marked as `@MainActor` since most of its properties are published and should be accessed from the main thread to avoid inconsistent SwiftUI state during renders
|
|
@MainActor
|
|
class CarouselModel: ObservableObject {
|
|
// MARK: Immutable object attributes
|
|
// These are some attributes that are not expected to change throughout the lifecycle of this object
|
|
// These should not be modified after initialization to avoid state inconsistency
|
|
|
|
/// The state of the app
|
|
let damus_state: DamusState
|
|
/// All urls in the carousel
|
|
let urls: [MediaUrl]
|
|
/// The default fill height for the carousel, if we cannot calculate a more appropriate height
|
|
/// **Usage note:** Default to this when `current_item_fill` is nil
|
|
let default_fill_height: CGFloat
|
|
/// The maximum height for any carousel item
|
|
let max_height: CGFloat
|
|
|
|
|
|
// MARK: Miscellaneous
|
|
|
|
/// Holds items that allows us to cancel video size observers during de-initialization
|
|
private var all_cancellables: [AnyCancellable] = []
|
|
|
|
|
|
// MARK: State management properties
|
|
/// Properties relevant to state management.
|
|
/// These should be made into computed/functional properties when possible to avoid stateful behavior
|
|
/// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies,
|
|
/// and use `didSet` observers to ensure that the state is always re-computed when necessary.
|
|
|
|
/// Stores information about the size of each media item in `urls`.
|
|
/// **Usage note:** The view is responsible for setting the size of image urls
|
|
var media_size_information: [URL: CGSize] {
|
|
didSet {
|
|
guard let current_url else { return }
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
/// Stores information about the geometry reader
|
|
/// **Usage note:** The view is responsible for setting this value
|
|
var geo_size: CGSize? {
|
|
didSet { self.refresh_current_item_fill() }
|
|
}
|
|
/// The index of the currently selected item
|
|
/// **Usage note:** The view is responsible for setting this value
|
|
@Published var selectedIndex: Int {
|
|
didSet { self.refresh_current_item_fill() }
|
|
}
|
|
/// The current fill for the media item.
|
|
/// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed.
|
|
var current_url: URL? {
|
|
return urls[safe: selectedIndex]?.url
|
|
}
|
|
/// Holds the ideal fill dimensions for the current item.
|
|
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly
|
|
/// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`,
|
|
/// 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
|
|
|
|
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array
|
|
init(damus_state: DamusState, urls: [MediaUrl]) {
|
|
// Immutable object attributes
|
|
self.damus_state = damus_state
|
|
self.urls = urls
|
|
self.default_fill_height = 350
|
|
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
|
|
|
|
// State management properties
|
|
self.selectedIndex = 0
|
|
self.current_item_fill = nil
|
|
self.geo_size = nil
|
|
self.media_size_information = [:]
|
|
|
|
// Setup the rest of the state management logic
|
|
self.observe_video_sizes()
|
|
Task {
|
|
self.refresh_current_item_fill()
|
|
self.refresh_first_item_height()
|
|
}
|
|
}
|
|
|
|
/// This private function observes the video sizes for all videos
|
|
private func observe_video_sizes() {
|
|
for media_url in urls {
|
|
switch media_url {
|
|
case .video(let url):
|
|
let video_player = damus_state.video.get_player(for: url)
|
|
if let video_size = video_player.video_size {
|
|
self.media_size_information[url] = video_size // Set the initial size if available
|
|
}
|
|
let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in
|
|
self.media_size_information[url] = new_size // Update the size when it changes
|
|
})
|
|
all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later
|
|
case .image(_):
|
|
break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
for cancellable_item in all_cancellables {
|
|
cancellable_item.cancel()
|
|
}
|
|
}
|
|
|
|
// MARK: State management and logic
|
|
|
|
/// This function refreshes the current item fill 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 fill
|
|
private func refresh_current_item_fill() {
|
|
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 {
|
|
return ImageFill.calculate_image_fill(
|
|
geo_size: geo_size,
|
|
img_size: item_size,
|
|
maxHeight: self.max_height,
|
|
fillHeight: self.default_fill_height
|
|
)
|
|
}
|
|
else {
|
|
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
|
|
|
|
/// A carousel that displays images and videos
|
|
///
|
|
/// ## Implementation notes
|
|
///
|
|
/// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view
|
|
///
|
|
@MainActor
|
|
struct ImageCarousel<Content: View>: View {
|
|
/// The event id of the note that this carousel is displaying
|
|
let evid: NoteId
|
|
/// The model that holds information and state of this carousel
|
|
/// This is observed to update the view when the model changes
|
|
@ObservedObject var model: CarouselModel
|
|
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
|
|
|
|
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
|
|
self.evid = evid
|
|
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
|
self.content = nil
|
|
}
|
|
|
|
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
|
|
self.evid = evid
|
|
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
|
|
self.content = content
|
|
}
|
|
|
|
/// 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 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 {
|
|
Group {
|
|
if num_urls > 1 {
|
|
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background
|
|
Color.clear
|
|
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url),
|
|
case .processed(let blurhash) = meta.state {
|
|
Image(uiImage: blurhash)
|
|
.resizable()
|
|
.frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale)
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
}
|
|
|
|
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
|
|
Group {
|
|
switch url {
|
|
case .image(let url):
|
|
Img(geo: geo, url: url, index: index)
|
|
.onTapGesture {
|
|
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
|
}
|
|
case .video(let url):
|
|
let video_model = model.damus_state.video.get_player(for: url)
|
|
DamusVideoPlayerView(
|
|
model: video_model,
|
|
coordinator: model.damus_state.video,
|
|
style: .preview(on_tap: {
|
|
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Img(geo: GeometryProxy, url: URL, index: Int) -> some View {
|
|
KFAnimatedImage(url)
|
|
.callbackQueue(.dispatch(.global(qos:.background)))
|
|
.backgroundDecode(true)
|
|
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation)
|
|
.image_fade(duration: 0.25)
|
|
.cancelOnDisappear(true)
|
|
.configure { view in
|
|
view.framePreloadCount = 3
|
|
}
|
|
.observe_image_size(size_changed: { size in
|
|
// Observe the image size to update the model when the size changes, so we can calculate the fill
|
|
model.media_size_information[url] = size
|
|
})
|
|
.background {
|
|
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count)
|
|
}
|
|
.aspectRatio(contentMode: filling ? .fill : .fit)
|
|
.kfClickable()
|
|
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
|
.tabItem {
|
|
Text(url.absoluteString)
|
|
}
|
|
.id(url.absoluteString)
|
|
.padding(0)
|
|
|
|
}
|
|
|
|
var Medias: some View {
|
|
TabView(selection: $model.selectedIndex) {
|
|
ForEach(model.urls.indices, id: \.self) { index in
|
|
GeometryReader { geo in
|
|
Media(geo: geo, url: model.urls[index], index: index)
|
|
.onChange(of: geo.size, perform: { new_size in
|
|
model.geo_size = new_size
|
|
})
|
|
.onAppear {
|
|
model.geo_size = geo.size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
if #available(iOS 18.0, *) {
|
|
Medias
|
|
} else {
|
|
// An empty tap gesture recognizer is needed on iOS 17 and below to suppress other overlapping tap recognizers
|
|
// Otherwise it will both open the carousel and go to a note at the same time
|
|
Medias.onTapGesture { }
|
|
}
|
|
|
|
|
|
if model.urls.count > 1 {
|
|
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count)
|
|
.frame(maxWidth: 0, maxHeight: 0)
|
|
.padding(.top, 5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public struct ImageFill {
|
|
let filling: Bool?
|
|
let height: CGFloat
|
|
|
|
static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill {
|
|
let shape = ImageShape.determine_image_shape(img_size)
|
|
|
|
let xfactor = geo_size.width / img_size.width
|
|
let scaled = img_size.height * xfactor
|
|
|
|
//print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)")
|
|
|
|
// calculate scaled image height
|
|
// set scale factor and constrain images to minimum 150
|
|
// and animations to scaled factor for dynamic size adjustment
|
|
switch shape {
|
|
case .portrait, .landscape:
|
|
let filling = scaled > maxHeight
|
|
let height = filling ? fillHeight : scaled
|
|
return ImageFill(filling: filling, height: height)
|
|
case .square, .unknown:
|
|
return ImageFill(filling: nil, height: scaled)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Provider
|
|
struct ImageCarousel_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
|
|
let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!)
|
|
ImageCarousel<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
|
|
.environmentObject(OrientationTracker())
|
|
}
|
|
}
|