ImageCarousel.swift (17942B)
1 // 2 // ImageCarousel.swift 3 // damus 4 // 5 // Created by William Casarin on 2022-10-16. 6 // 7 8 import SwiftUI 9 import Kingfisher 10 import Combine 11 12 // TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16 13 struct ShareSheet: UIViewControllerRepresentable { 14 typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void 15 16 let activityItems: [URL?] 17 let callback: Callback? = nil 18 let applicationActivities: [UIActivity]? = nil 19 let excludedActivityTypes: [UIActivity.ActivityType]? = nil 20 21 func makeUIViewController(context: Context) -> UIActivityViewController { 22 let controller = UIActivityViewController( 23 activityItems: activityItems as [Any], 24 applicationActivities: applicationActivities) 25 controller.excludedActivityTypes = excludedActivityTypes 26 controller.completionWithItemsHandler = callback 27 return controller 28 } 29 30 func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { 31 // nothing to do here 32 } 33 } 34 35 // Custom UIPageControl 36 struct PageControlView: UIViewRepresentable { 37 @Binding var currentPage: Int 38 var numberOfPages: Int 39 40 func makeCoordinator() -> Coordinator { 41 Coordinator(self) 42 } 43 44 func makeUIView(context: Context) -> UIPageControl { 45 let uiView = UIPageControl() 46 uiView.backgroundStyle = .minimal 47 uiView.currentPageIndicatorTintColor = UIColor(Color("DamusPurple")) 48 uiView.pageIndicatorTintColor = UIColor(Color("DamusLightGrey")) 49 uiView.currentPage = currentPage 50 uiView.numberOfPages = numberOfPages 51 uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged) 52 return uiView 53 } 54 55 func updateUIView(_ uiView: UIPageControl, context: Context) { 56 uiView.currentPage = currentPage 57 uiView.numberOfPages = numberOfPages 58 } 59 } 60 61 extension PageControlView { 62 final class Coordinator: NSObject { 63 var parent: PageControlView 64 65 init(_ parent: PageControlView) { 66 self.parent = parent 67 } 68 69 @objc func valueChanged(sender: UIPageControl) { 70 let currentPage = sender.currentPage 71 withAnimation { 72 parent.currentPage = currentPage 73 } 74 } 75 } 76 } 77 78 79 enum ImageShape { 80 case square 81 case landscape 82 case portrait 83 case unknown 84 85 static func determine_image_shape(_ size: CGSize) -> ImageShape { 86 guard size.height > 0 else { 87 return .unknown 88 } 89 let imageRatio = size.width / size.height 90 switch imageRatio { 91 case 1.0: return .square 92 case ..<1.0: return .portrait 93 case 1.0...: return .landscape 94 default: return .unknown 95 } 96 } 97 } 98 99 /// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size 100 /// 101 /// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items, 102 /// and the ideal display size at each moment is not a trivial task. 103 /// 104 /// The rules for the media fill are as follows: 105 /// 1. The media item should generally have a width that completely fills the width of its parent view 106 /// 2. The height of the carousel should be adjusted accordingly 107 /// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device 108 /// 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. 109 /// 110 /// ## Usage notes 111 /// 112 /// The view is has the following state management responsibilities: 113 /// 1. Watching the size of the images (via the `.observe_image_size` modifier) 114 /// 2. Notifying this class of geometry reader changes, by setting `geo_size` 115 /// 116 /// ## Implementation notes 117 /// 118 /// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause. 119 /// 120 /// This is accomplished through the following pattern: 121 /// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view 122 /// 2. However, `current_item_fill` has a mathematical dependency on other members of this class 123 /// 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. 124 /// 125 /// This pattern helps ensure that the state is always consistent and that the view is always up-to-date. 126 /// 127 /// 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 128 @MainActor 129 class CarouselModel: ObservableObject { 130 // MARK: Immutable object attributes 131 // These are some attributes that are not expected to change throughout the lifecycle of this object 132 // These should not be modified after initialization to avoid state inconsistency 133 134 /// The state of the app 135 let damus_state: DamusState 136 /// All urls in the carousel 137 let urls: [MediaUrl] 138 /// The default fill height for the carousel, if we cannot calculate a more appropriate height 139 /// **Usage note:** Default to this when `current_item_fill` is nil 140 let default_fill_height: CGFloat 141 /// The maximum height for any carousel item 142 let max_height: CGFloat 143 144 145 // MARK: Miscellaneous 146 147 /// Holds items that allows us to cancel video size observers during de-initialization 148 private var all_cancellables: [AnyCancellable] = [] 149 150 151 // MARK: State management properties 152 /// Properties relevant to state management. 153 /// These should be made into computed/functional properties when possible to avoid stateful behavior 154 /// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies, 155 /// and use `didSet` observers to ensure that the state is always re-computed when necessary. 156 157 /// Stores information about the size of each media item in `urls`. 158 /// **Usage note:** The view is responsible for setting the size of image urls 159 var media_size_information: [URL: CGSize] { 160 didSet { 161 guard let current_url else { return } 162 // Upon updating information, update the carousel fill size if the size for the current url has changed 163 if oldValue[current_url] != media_size_information[current_url] { 164 self.refresh_current_item_fill() 165 } 166 } 167 } 168 /// Stores information about the geometry reader 169 /// **Usage note:** The view is responsible for setting this value 170 var geo_size: CGSize? { 171 didSet { self.refresh_current_item_fill() } 172 } 173 /// The index of the currently selected item 174 /// **Usage note:** The view is responsible for setting this value 175 @Published var selectedIndex: Int { 176 didSet { self.refresh_current_item_fill() } 177 } 178 /// The current fill for the media item. 179 /// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed. 180 var current_url: URL? { 181 return urls[safe: selectedIndex]?.url 182 } 183 /// Holds the ideal fill dimensions for the current item. 184 /// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly 185 /// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`, 186 /// and is automatically updated upon changes to these properties. 187 @Published private(set) var current_item_fill: ImageFill? 188 189 190 // MARK: Initialization and de-initialization 191 192 /// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array 193 init(damus_state: DamusState, urls: [MediaUrl]) { 194 // Immutable object attributes 195 self.damus_state = damus_state 196 self.urls = urls 197 self.default_fill_height = 350 198 self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2 199 200 // State management properties 201 self.selectedIndex = 0 202 self.current_item_fill = nil 203 self.geo_size = nil 204 self.media_size_information = [:] 205 206 // Setup the rest of the state management logic 207 self.observe_video_sizes() 208 Task { 209 self.refresh_current_item_fill() 210 } 211 } 212 213 /// This private function observes the video sizes for all videos 214 private func observe_video_sizes() { 215 for media_url in urls { 216 switch media_url { 217 case .video(let url): 218 let video_player = damus_state.video.get_player(for: url) 219 if let video_size = video_player.video_size { 220 self.media_size_information[url] = video_size // Set the initial size if available 221 } 222 let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in 223 self.media_size_information[url] = new_size // Update the size when it changes 224 }) 225 all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later 226 case .image(_): 227 break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier 228 } 229 } 230 } 231 232 deinit { 233 for cancellable_item in all_cancellables { 234 cancellable_item.cancel() 235 } 236 } 237 238 // MARK: State management and logic 239 240 /// This function refreshes the current item fill based on the current state of the model 241 /// **Usage note:** This is private, do not call this directly from outside the class. 242 /// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill 243 private func refresh_current_item_fill() { 244 if let current_url, 245 let item_size = self.media_size_information[current_url], 246 let geo_size { 247 self.current_item_fill = ImageFill.calculate_image_fill( 248 geo_size: geo_size, 249 img_size: item_size, 250 maxHeight: self.max_height, 251 fillHeight: self.default_fill_height 252 ) 253 } 254 else { 255 self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil 256 } 257 } 258 } 259 260 // MARK: - Image Carousel 261 262 /// A carousel that displays images and videos 263 /// 264 /// ## Implementation notes 265 /// 266 /// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view 267 /// 268 @MainActor 269 struct ImageCarousel<Content: View>: View { 270 /// The event id of the note that this carousel is displaying 271 let evid: NoteId 272 /// The model that holds information and state of this carousel 273 /// This is observed to update the view when the model changes 274 @ObservedObject var model: CarouselModel 275 let content: ((_ dismiss: @escaping (() -> Void)) -> Content)? 276 277 init(state: DamusState, evid: NoteId, urls: [MediaUrl]) { 278 self.evid = evid 279 self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls)) 280 self.content = nil 281 } 282 283 init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) { 284 self.evid = evid 285 self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls)) 286 self.content = content 287 } 288 289 var filling: Bool { 290 model.current_item_fill?.filling == true 291 } 292 293 var height: CGFloat { 294 // Use the calculated fill height if available, otherwise use the default fill height 295 model.current_item_fill?.height ?? model.default_fill_height 296 } 297 298 func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View { 299 Group { 300 if num_urls > 1 { 301 // jb55: quick hack since carousel with multiple images looks horrible with blurhash background 302 Color.clear 303 } else if let meta = model.damus_state.events.lookup_img_metadata(url: url), 304 case .processed(let blurhash) = meta.state { 305 Image(uiImage: blurhash) 306 .resizable() 307 .frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale) 308 } else { 309 Color.clear 310 } 311 } 312 } 313 314 func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View { 315 Group { 316 switch url { 317 case .image(let url): 318 Img(geo: geo, url: url, index: index) 319 .onTapGesture { 320 present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex)) 321 } 322 case .video(let url): 323 let video_model = model.damus_state.video.get_player(for: url) 324 DamusVideoPlayerView( 325 model: video_model, 326 coordinator: model.damus_state.video, 327 style: .preview(on_tap: { 328 present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex)) 329 }) 330 ) 331 } 332 } 333 } 334 335 func Img(geo: GeometryProxy, url: URL, index: Int) -> some View { 336 KFAnimatedImage(url) 337 .callbackQueue(.dispatch(.global(qos:.background))) 338 .backgroundDecode(true) 339 .imageContext(.note, disable_animation: model.damus_state.settings.disable_animation) 340 .image_fade(duration: 0.25) 341 .cancelOnDisappear(true) 342 .configure { view in 343 view.framePreloadCount = 3 344 } 345 .observe_image_size(size_changed: { size in 346 // Observe the image size to update the model when the size changes, so we can calculate the fill 347 model.media_size_information[url] = size 348 }) 349 .background { 350 Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count) 351 } 352 .aspectRatio(contentMode: filling ? .fill : .fit) 353 .kfClickable() 354 .position(x: geo.size.width / 2, y: geo.size.height / 2) 355 .tabItem { 356 Text(url.absoluteString) 357 } 358 .id(url.absoluteString) 359 .padding(0) 360 361 } 362 363 var Medias: some View { 364 TabView(selection: $model.selectedIndex) { 365 ForEach(model.urls.indices, id: \.self) { index in 366 GeometryReader { geo in 367 Media(geo: geo, url: model.urls[index], index: index) 368 .onChange(of: geo.size, perform: { new_size in 369 model.geo_size = new_size 370 }) 371 .onAppear { 372 model.geo_size = geo.size 373 } 374 } 375 } 376 } 377 .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) 378 .frame(height: height) 379 .onChange(of: model.selectedIndex) { value in 380 model.selectedIndex = value 381 } 382 } 383 384 var body: some View { 385 VStack { 386 if #available(iOS 18.0, *) { 387 Medias 388 } else { 389 // An empty tap gesture recognizer is needed on iOS 17 and below to suppress other overlapping tap recognizers 390 // Otherwise it will both open the carousel and go to a note at the same time 391 Medias.onTapGesture { } 392 } 393 394 395 if model.urls.count > 1 { 396 PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count) 397 .frame(maxWidth: 0, maxHeight: 0) 398 .padding(.top, 5) 399 } 400 } 401 } 402 } 403 404 405 public struct ImageFill { 406 let filling: Bool? 407 let height: CGFloat 408 409 static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill { 410 let shape = ImageShape.determine_image_shape(img_size) 411 412 let xfactor = geo_size.width / img_size.width 413 let scaled = img_size.height * xfactor 414 415 //print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)") 416 417 // calculate scaled image height 418 // set scale factor and constrain images to minimum 150 419 // and animations to scaled factor for dynamic size adjustment 420 switch shape { 421 case .portrait, .landscape: 422 let filling = scaled > maxHeight 423 let height = filling ? fillHeight : scaled 424 return ImageFill(filling: filling, height: height) 425 case .square, .unknown: 426 return ImageFill(filling: nil, height: scaled) 427 } 428 } 429 } 430 431 // MARK: - Preview Provider 432 struct ImageCarousel_Previews: PreviewProvider { 433 static var previews: some View { 434 let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) 435 let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) 436 ImageCarousel<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url]) 437 .environmentObject(OrientationTracker()) 438 } 439 }