damus

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

ImageCarousel.swift (12773B)


      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 
     11 // TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
     12 struct ShareSheet: UIViewControllerRepresentable {
     13     typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void
     14     
     15     let activityItems: [URL?]
     16     let callback: Callback? = nil
     17     let applicationActivities: [UIActivity]? = nil
     18     let excludedActivityTypes: [UIActivity.ActivityType]? = nil
     19     
     20     func makeUIViewController(context: Context) -> UIActivityViewController {
     21         let controller = UIActivityViewController(
     22             activityItems: activityItems as [Any],
     23             applicationActivities: applicationActivities)
     24         controller.excludedActivityTypes = excludedActivityTypes
     25         controller.completionWithItemsHandler = callback
     26         return controller
     27     }
     28     
     29     func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
     30         // nothing to do here
     31     }
     32 }
     33 
     34 //  Custom UIPageControl
     35 struct PageControlView: UIViewRepresentable {
     36     @Binding var currentPage: Int
     37     var numberOfPages: Int
     38     
     39     func makeCoordinator() -> Coordinator {
     40         Coordinator(self)
     41     }
     42 
     43     func makeUIView(context: Context) -> UIPageControl {
     44         let uiView = UIPageControl()
     45         uiView.backgroundStyle = .minimal
     46         uiView.currentPageIndicatorTintColor = UIColor(Color("DamusPurple"))
     47         uiView.pageIndicatorTintColor = UIColor(Color("DamusLightGrey"))
     48         uiView.currentPage = currentPage
     49         uiView.numberOfPages = numberOfPages
     50         uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
     51         return uiView
     52     }
     53 
     54     func updateUIView(_ uiView: UIPageControl, context: Context) {
     55         uiView.currentPage = currentPage
     56         uiView.numberOfPages = numberOfPages
     57     }
     58 }
     59 
     60 extension PageControlView {
     61     final class Coordinator: NSObject {
     62         var parent: PageControlView
     63         
     64         init(_ parent: PageControlView) {
     65             self.parent = parent
     66         }
     67         
     68         @objc func valueChanged(sender: UIPageControl) {
     69             let currentPage = sender.currentPage
     70             withAnimation {
     71                 parent.currentPage = currentPage
     72             }
     73         }
     74     }
     75 }
     76 
     77 
     78 enum ImageShape {
     79     case square
     80     case landscape
     81     case portrait
     82     case unknown
     83     
     84     static func determine_image_shape(_ size: CGSize) -> ImageShape {
     85         guard size.height > 0 else {
     86             return .unknown
     87         }
     88         let imageRatio = size.width / size.height
     89         switch imageRatio {
     90             case 1.0: return .square
     91             case ..<1.0: return .portrait
     92             case 1.0...: return .landscape
     93             default: return .unknown
     94         }
     95     }
     96 }
     97 
     98 class CarouselModel: ObservableObject {
     99     var current_url: URL?
    100     var fillHeight: CGFloat
    101     var maxHeight: CGFloat
    102     var firstImageHeight: CGFloat?
    103 
    104     @Published var open_sheet: Bool
    105     @Published var selectedIndex: Int
    106     @Published var video_size: CGSize?
    107     @Published var image_fill: ImageFill?
    108 
    109     init(image_fill: ImageFill?) {
    110         self.current_url = nil
    111         self.fillHeight = 350
    112         self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
    113         self.firstImageHeight = nil
    114         self.open_sheet = false
    115         self.selectedIndex = 0
    116         self.video_size = nil
    117         self.image_fill = image_fill
    118     }
    119 }
    120 
    121 // MARK: - Image Carousel
    122 @MainActor
    123 struct ImageCarousel<Content: View>: View {
    124     var urls: [MediaUrl]
    125     
    126     let evid: NoteId
    127     
    128     let state: DamusState
    129     @ObservedObject var model: CarouselModel
    130     let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
    131 
    132     init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
    133         self.urls = urls
    134         self.evid = evid
    135         self.state = state
    136         let media_model = state.events.get_cache_data(evid).media_metadata_model
    137         self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
    138         self.content = nil
    139     }
    140     
    141     init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
    142         self.urls = urls
    143         self.evid = evid
    144         self.state = state
    145         let media_model = state.events.get_cache_data(evid).media_metadata_model
    146         self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
    147         self.content = content
    148     }
    149     
    150     var filling: Bool {
    151         model.image_fill?.filling == true
    152     }
    153     
    154     var height: CGFloat {
    155         model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
    156     }
    157     
    158     func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
    159         Group {
    160             if num_urls > 1 {
    161                 // jb55: quick hack since carousel with multiple images looks horrible with blurhash background
    162                 Color.clear
    163             } else if let meta = state.events.lookup_img_metadata(url: url),
    164                case .processed(let blurhash) = meta.state {
    165                 Image(uiImage: blurhash)
    166                     .resizable()
    167                     .frame(width: geo_size.width * UIScreen.main.scale, height: self.height * UIScreen.main.scale)
    168             } else {
    169                 Color.clear
    170             }
    171         }
    172         .onAppear {
    173             if self.model.image_fill == nil, let size = state.video.size_for_url(url) {
    174                 let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
    175                 self.model.image_fill = fill
    176             }
    177         }
    178     }
    179     
    180     func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
    181         Group {
    182             switch url {
    183             case .image(let url):
    184                 Img(geo: geo, url: url, index: index)
    185                     .onTapGesture {
    186                         model.open_sheet = true
    187                     }
    188             case .video(let url):
    189                 DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
    190                     .onChange(of: model.video_size) { size in
    191                         guard let size else { return }
    192                         
    193                         let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
    194 
    195                         print("video_size changed \(size)")
    196                         if self.model.image_fill == nil {
    197                             print("video_size firstImageHeight \(fill.height)")
    198                             self.model.firstImageHeight = fill.height
    199                             state.events.get_cache_data(evid).media_metadata_model.fill = fill
    200                         }
    201                         
    202                         self.model.image_fill = fill
    203                     }
    204             }
    205         }
    206     }
    207     
    208     func Img(geo: GeometryProxy, url: URL, index: Int) -> some View {
    209         KFAnimatedImage(url)
    210             .callbackQueue(.dispatch(.global(qos:.background)))
    211             .backgroundDecode(true)
    212             .imageContext(.note, disable_animation: state.settings.disable_animation)
    213             .image_fade(duration: 0.25)
    214             .cancelOnDisappear(true)
    215             .configure { view in
    216                 view.framePreloadCount = 3
    217             }
    218             .imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
    219                 state.events.get_cache_data(evid).media_metadata_model.fill = fill
    220                 // blur hash can be discarded when we have the url
    221                 // NOTE: this is the wrong place for this... we need to remove
    222                 //       it when the image is loaded in memory. This may happen
    223                 //       earlier than this (by the preloader, etc)
    224                 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
    225                     state.events.lookup_img_metadata(url: url)?.state = .not_needed
    226                 }
    227                 self.model.image_fill = fill
    228                 if index == 0 {
    229                     self.model.firstImageHeight = fill.height
    230                     //maxHeight = firstImageHeight ?? maxHeight
    231                 } else {
    232                     //maxHeight = firstImageHeight ?? fill.height
    233                 }
    234             }
    235             .background {
    236                 Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
    237             }
    238             .aspectRatio(contentMode: filling ? .fill : .fit)
    239             .position(x: geo.size.width / 2, y: geo.size.height / 2)
    240             .tabItem {
    241                 Text(url.absoluteString)
    242             }
    243             .id(url.absoluteString)
    244             .padding(0)
    245                 
    246     }
    247     
    248     var Medias: some View {
    249         TabView(selection: $model.selectedIndex) {
    250             ForEach(urls.indices, id: \.self) { index in
    251                 GeometryReader { geo in
    252                     Media(geo: geo, url: urls[index], index: index)
    253                 }
    254             }
    255         }
    256         .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    257         .fullScreenCover(isPresented: $model.open_sheet) {
    258             if let content {
    259                 FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
    260                     content({ // Dismiss closure
    261                         model.open_sheet = false
    262                     })
    263                 }
    264             }
    265             else {
    266                 FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
    267             }
    268         }
    269         .frame(height: height)
    270         .onChange(of: model.selectedIndex) { value in
    271             model.selectedIndex = value
    272         }
    273     }
    274     
    275     var body: some View {
    276         VStack {
    277             Medias
    278                 .onTapGesture { }
    279             
    280             if urls.count > 1 {
    281                 PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
    282                     .frame(maxWidth: 0, maxHeight: 0)
    283                     .padding(.top, 5)
    284             }
    285         }
    286     }
    287 }
    288 
    289 // MARK: - Image Modifier
    290 extension KFOptionSetter {
    291     /// Sets a block to get image size
    292     ///
    293     /// - Parameter block: The block which is used to read the image object.
    294     /// - Returns: `Self` value after read size
    295     public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
    296         let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
    297             let img_size = image.size
    298             let geo_size = size
    299             let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
    300             DispatchQueue.main.async { [block, fill] in
    301                 try? block(fill)
    302             }
    303             return image
    304         }
    305         options.imageModifier = modifier
    306         return self
    307     }
    308 }
    309 
    310 
    311 public struct ImageFill {
    312     let filling: Bool?
    313     let height: CGFloat
    314         
    315     static func calculate_image_fill(geo_size: CGSize, img_size: CGSize, maxHeight: CGFloat, fillHeight: CGFloat) -> ImageFill {
    316         let shape = ImageShape.determine_image_shape(img_size)
    317 
    318         let xfactor = geo_size.width / img_size.width
    319         let scaled = img_size.height * xfactor
    320         
    321         //print("calc_img_fill \(img_size.width)x\(img_size.height) xfactor:\(xfactor) scaled:\(scaled)")
    322         
    323         // calculate scaled image height
    324         // set scale factor and constrain images to minimum 150
    325         // and animations to scaled factor for dynamic size adjustment
    326         switch shape {
    327         case .portrait, .landscape:
    328             let filling = scaled > maxHeight
    329             let height = filling ? fillHeight : scaled
    330             return ImageFill(filling: filling, height: height)
    331         case .square, .unknown:
    332             return ImageFill(filling: nil, height: scaled)
    333         }
    334     }
    335 }
    336 
    337 // MARK: - Preview Provider
    338 struct ImageCarousel_Previews: PreviewProvider {
    339     static var previews: some View {
    340         let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
    341         let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!)
    342         ImageCarousel<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
    343             .environmentObject(OrientationTracker())
    344     }
    345 }
    346