damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }