diff --git a/ForPDA.xcodeproj/project.pbxproj b/ForPDA.xcodeproj/project.pbxproj index 1d08efa4..7594bde5 100644 --- a/ForPDA.xcodeproj/project.pbxproj +++ b/ForPDA.xcodeproj/project.pbxproj @@ -353,6 +353,7 @@ 66FC76ADFBEF155B4AD5684E /* SortType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124DA2BF5DC419EB71D063EE /* SortType.swift */; }; 6757DAF4542B89F4E49037F5 /* SFSafeSymbols.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 322E22C74D44156FAE5F3F7B /* SFSafeSymbols.framework */; }; 67A6B4599E52C547BA1DF046 /* FavoriteRootFeature+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E759700E6C2878F605BBEC0 /* FavoriteRootFeature+Analytics.swift */; }; + 67BA9AB34F23890684D33638 /* TabViewGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */; }; 67DCC37FFEA96AA2B7892715 /* ComposableArchitecture.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F42D2398CAA67D4000962E67 /* ComposableArchitecture.framework */; }; 681BDDE56CBE8E4014DFE5E3 /* NotificationsFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 006917E2DFC70599AF512EA1 /* NotificationsFeature.framework */; }; 68DF677F404827BB69F6F0FE /* Models.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD51C6E965A35DBF9F4FBA55 /* Models.framework */; }; @@ -676,6 +677,7 @@ C8C174A7838AA886FEC1D7D6 /* Models.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD51C6E965A35DBF9F4FBA55 /* Models.framework */; }; C9BB21B7E3604B43D7945AAE /* ArticleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99F23D68A39F53CF4566F1A /* ArticleParser.swift */; }; CA65F52046767C63F6B38B83 /* LoginEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FD9D2DDE325720FC78D6A0 /* LoginEvent.swift */; }; + CA8BBC8E71A6DF343FCE120A /* ImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */; }; CAA93B384FC9055B705904F7 /* ArticlesListFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A39BD5202EED5434C358B34E /* ArticlesListFeature.framework */; }; CABBF3C4FAF86859FA053854 /* content.js in Resources */ = {isa = PBXBuildFile; fileRef = 98A2BB020C3E971CB0991A5E /* content.js */; }; CABDE1660FCCBA352BD96785 /* NotificationsClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 889505F119E7D81F1A1B9E41 /* NotificationsClient.framework */; }; @@ -747,6 +749,7 @@ DE4D4F43FA68F91A454AC76F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63C087325941C6CC8EDB1EA8 /* Preview Assets.xcassets */; }; DEC0DE86920768F3141234FE /* CombineSchedulers.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 94F0AD33CFEC2D542AD5BC7C /* CombineSchedulers.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DED7880C6B8C32503A0D4FBA /* ArticlePoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048460D126E2C26FEB6265DC /* ArticlePoll.swift */; }; + DEEC77E32E9C1693FD3C2BC1 /* CustomScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */; }; DF4551A19CB85081DE3D30EB /* Cache_Cache.bundle in Dependencies */ = {isa = PBXBuildFile; fileRef = 4BAC16218EC9F9A9F2999ABB /* Cache_Cache.bundle */; }; DFD97D366B0725ADD3EFF5E6 /* HistoryFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02A126A4634AB82351345379 /* HistoryFeature.framework */; }; DFE1FC541AF185F6935349F3 /* ForumRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F2AB27D0AE7F79E68C49FF /* ForumRow.swift */; }; @@ -2839,6 +2842,7 @@ 67177EBF73A3EAA0B80AEE24 /* TuistFonts+CacheClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+CacheClient.swift"; sourceTree = ""; }; 6721D235B3FA36225B3C8144 /* TuistAssets+BookmarksFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistAssets+BookmarksFeature.swift"; sourceTree = ""; }; 67482FDA32E710F193B122C5 /* DeveloperFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DeveloperFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCollectionViewCell.swift; sourceTree = ""; }; 6764C3EB2945A53038BCE307 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 67B79800FEE68FB8EA1B8F40 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 69D06482712444A605E2B9C6 /* TopicBuilder.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TopicBuilder.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2881,6 +2885,7 @@ 80EED22D45233E216DFED0EA /* TuistAssets+ArticleFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistAssets+ArticleFeature.swift"; sourceTree = ""; }; 812721A72ADCFCDF77E41819 /* QMSListFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = QMSListFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 819C3F42D1C0667301523468 /* HistoryFeature-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "HistoryFeature-Info.plist"; sourceTree = ""; }; + 81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScrollView.swift; sourceTree = ""; }; 82DB60D4B9083EC073BFC07C /* FavoritesRootEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesRootEvent.swift; sourceTree = ""; }; 83FB4761086E2397F004D41A /* SwiftyGif_SwiftyGif.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftyGif_SwiftyGif.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 85CCC7B42618527254A75999 /* FavoritesFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesFeature.swift; sourceTree = ""; }; @@ -2959,6 +2964,7 @@ B06C779165D24CB727795AEB /* TuistFonts+NotificationsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+NotificationsFeature.swift"; sourceTree = ""; }; B28971DBCF658964A52E32E7 /* ArticleElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleElementView.swift; sourceTree = ""; }; B2A7090197422BB1A461D825 /* BBAttributedTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BBAttributedTokenizer.swift; sourceTree = ""; }; + B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewGallery.swift; sourceTree = ""; }; B487A0FA81FA470EE3027064 /* TopicFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicFeature.swift; sourceTree = ""; }; B4EC2971DC481D53169646D1 /* TuistFonts+NotificationsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+NotificationsClient.swift"; sourceTree = ""; }; B55C906394BB2ECF59F0CEC9 /* ToastInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastInfo.swift; sourceTree = ""; }; @@ -3853,6 +3859,16 @@ path = Analytics; sourceTree = ""; }; + 18109C296EB581D582CE1747 /* Gallery */ = { + isa = PBXGroup; + children = ( + 81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */, + 67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */, + B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */, + ); + path = Gallery; + sourceTree = ""; + }; 1DB9BCAE34C7291DA91CE3BE /* Models */ = { isa = PBXGroup; children = ( @@ -4948,6 +4964,7 @@ BD12CB093819B7C6DC7B1FE3 /* Views */ = { isa = PBXGroup; children = ( + 18109C296EB581D582CE1747 /* Gallery */, B28971DBCF658964A52E32E7 /* ArticleElementView.swift */, 101DA82283F6CEA438667EB7 /* ArticleMenu.swift */, 4DA58394E7006E74B711137B /* ParallaxHeader.swift */, @@ -7389,6 +7406,9 @@ 29DE94F1CF7D57B0C72C8948 /* ArticleMenuAction.swift in Sources */, 5FF953A9137820F5DBE72EEB /* ArticleElementView.swift in Sources */, 875B98063EE3978A99CF7680 /* ArticleMenu.swift in Sources */, + DEEC77E32E9C1693FD3C2BC1 /* CustomScrollView.swift in Sources */, + CA8BBC8E71A6DF343FCE120A /* ImageCollectionViewCell.swift in Sources */, + 67BA9AB34F23890684D33638 /* TabViewGallery.swift in Sources */, B5DB8BDE49B18806E1E790BA /* ParallaxHeader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings b/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings index f032b483..fe3419cd 100644 --- a/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings @@ -163,6 +163,26 @@ } } }, + "Save" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + } + } + }, + "Share" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться" + } + } + } + }, "Share Link" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ArticleFeature/Views/ArticleElementView.swift b/Modules/Sources/ArticleFeature/Views/ArticleElementView.swift index 3308507f..86fc5765 100644 --- a/Modules/Sources/ArticleFeature/Views/ArticleElementView.swift +++ b/Modules/Sources/ArticleFeature/Views/ArticleElementView.swift @@ -20,6 +20,8 @@ struct ArticleElementView: View { @State private var gallerySelection: Int = 0 @State private var pollSelection: ArticlePoll.Option? @State private var pollSelections: Set = .init() + @State private var showFullScreenGallery = false + @State private var selectedImageID = 0 private var hasSelection: Bool { return pollSelection != nil || !pollSelections.isEmpty @@ -113,6 +115,12 @@ struct ArticleElementView: View { .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * element.ratioHW) .clipped() + .onTapGesture { + showFullScreenGallery.toggle() + } + .fullScreenCover(isPresented: $showFullScreenGallery) { + TabViewGallery(gallery: [element.url], selectedImageID: selectedImageID) + } } // MARK: - Gallery @@ -120,7 +128,7 @@ struct ArticleElementView: View { @ViewBuilder private func gallery(_ element: [ImageElement]) -> some View { TabView { - ForEach(element, id: \.self) { imageElement in + ForEach(Array(element.enumerated()), id: \.element) { index, imageElement in LazyImage(url: imageElement.url) { state in Group { if let image = state.image { @@ -133,6 +141,12 @@ struct ArticleElementView: View { } .aspectRatio(imageElement.ratioWH, contentMode: .fit) .clipped() + .highPriorityGesture( + TapGesture().onEnded { + showFullScreenGallery.toggle() + selectedImageID = index + } + ) } .padding(.bottom, 48) // Fix against index overlaying } @@ -140,6 +154,9 @@ struct ArticleElementView: View { .tabViewStyle(.page(indexDisplayMode: .always)) .indexViewStyle(.page(backgroundDisplayMode: .always)) .padding(.bottom, -16) + .fullScreenCover(isPresented: $showFullScreenGallery) { + TabViewGallery(gallery: element.map{ $0.url }, selectedImageID: selectedImageID) + } } // MARK: - Video diff --git a/Modules/Sources/ArticleFeature/Views/Gallery/CustomScrollView.swift b/Modules/Sources/ArticleFeature/Views/Gallery/CustomScrollView.swift new file mode 100644 index 00000000..eeac07dc --- /dev/null +++ b/Modules/Sources/ArticleFeature/Views/Gallery/CustomScrollView.swift @@ -0,0 +1,181 @@ +// +// CustomScrollView.swift +// ArticleFeature +// +// Created by Виталий Канин on 11.03.2025. +// + +import SwiftUI +import Models + +struct CustomScrollView: UIViewRepresentable { + + let imageElement: [URL] + @Binding var selectedIndex: Int + @Binding var isZooming: Bool + @Binding var isTouched: Bool + @Binding var backgroundOpacity: Double + var onClose: (() -> Void)? + + func makeUIView(context: Context) -> UICollectionView { + + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.itemSize = UIScreen.main.bounds.size + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.isPagingEnabled = true + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.dataSource = context.coordinator + collectionView.delegate = context.coordinator + collectionView.backgroundColor = .black + collectionView.register(ImageCollectionViewCell.self, forCellWithReuseIdentifier: "ImageCollectionViewCell") + + let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleVerticalSwipe(_:))) + panGesture.delegate = context.coordinator as any UIGestureRecognizerDelegate + collectionView.addGestureRecognizer(panGesture) + + return collectionView + } + + func updateUIView(_ uiView: UICollectionView, context: Context) { + let indexPath = IndexPath(item: selectedIndex, section: 0) + Task { @MainActor in + uiView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate{ + var parent: CustomScrollView + private var initialTouchPoint: CGPoint = .zero + private var firstSwipeDirection: SwipeDirection = .none + + enum SwipeDirection { + case horizontal + case vertical + case none + } + + init(_ parent: CustomScrollView) { + self.parent = parent + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return parent.imageElement.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCollectionViewCell", for: indexPath) as! ImageCollectionViewCell + cell.setImage(url: parent.imageElement[indexPath.item]) + + cell.onZoom = { isZooming in + Task { @MainActor in + self.parent.isZooming = isZooming + } + } + + cell.onToolBar = { + Task { @MainActor in + self.parent.isTouched.toggle() + } + } + return cell + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + let pageIndex = Int(scrollView.contentOffset.x / scrollView.bounds.width) + self.parent.selectedIndex = pageIndex + + // We always have a last gesture + if let gestureRecognizers = scrollView.gestureRecognizers, let lastGesture = gestureRecognizers.last { + lastGesture.isEnabled = true + } + + firstSwipeDirection = .none + } + + @objc func handleVerticalSwipe(_ gesture: UIPanGestureRecognizer) { + guard let collectionView = gesture.view as? UICollectionView else { return } + guard let visibleCell = collectionView.visibleCells.first as? ImageCollectionViewCell else { return } + let translation = gesture.translation(in: gesture.view?.superview) + if parent.isZooming { return } + + switch gesture.state { + case .began: + initialTouchPoint = gesture.location(in: gesture.view?.superview) + case .changed: + if abs(translation.y) > abs(translation.x) && firstSwipeDirection == .vertical { + collectionView.isScrollEnabled = false + visibleCell.transform = CGAffineTransform(translationX: 0, y: translation.y) + self.parent.backgroundOpacity = max(0.1, 1 - Double(abs(translation.y * 5) / 700)) + collectionView.layer.opacity = max(0.1, 1 - Float(abs(translation.y * 2.5) / 700)) + } + case .ended, .cancelled: + if abs(translation.y) > 150 { + parent.onClose?() + UIView.animate(withDuration: 0.6, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.3, + options: .curveEaseInOut, + animations: { + self.parent.backgroundOpacity = 0.0 + collectionView.layer.opacity = 0.0 + }) + } else { + UIView.animate(withDuration: 0.6, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.3, + options: .curveEaseInOut, + animations: { + visibleCell.transform = CGAffineTransform(translationX: 0, y: 0) + self.parent.backgroundOpacity = 1.0 + collectionView.layer.opacity = 1.0 + }) + } + firstSwipeDirection = .none + collectionView.isScrollEnabled = true + case .failed, .possible: + break + @unknown default: + break + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + guard let collection = otherGestureRecognizer.view as? UICollectionView else { + return false + } + + if parent.imageElement.count == 1 { + firstSwipeDirection = .vertical + return true + } + + if let panGesture = gestureRecognizer as? UIPanGestureRecognizer { + let velocity = panGesture.velocity(in: panGesture.view) + if firstSwipeDirection == .none { + if abs(velocity.x) > abs(velocity.y) { + firstSwipeDirection = .horizontal + collection.isScrollEnabled = true + panGesture.isEnabled = false // + } else if abs(velocity.x) < abs(velocity.y) { + firstSwipeDirection = .vertical + otherGestureRecognizer.isEnabled = true + collection.isScrollEnabled = false + } + } + } + + return true + } + } +} diff --git a/Modules/Sources/ArticleFeature/Views/Gallery/ImageCollectionViewCell.swift b/Modules/Sources/ArticleFeature/Views/Gallery/ImageCollectionViewCell.swift new file mode 100644 index 00000000..547bed44 --- /dev/null +++ b/Modules/Sources/ArticleFeature/Views/Gallery/ImageCollectionViewCell.swift @@ -0,0 +1,117 @@ +// +// ImageCollectionViewCell.swift +// ArticleFeature +// +// Created by Виталий Канин on 12.03.2025. +// + +import SwiftUI +import Nuke +import NukeUI + +class ImageCollectionViewCell: UICollectionViewCell, UIScrollViewDelegate { + private let scrollView = UIScrollView() + private let imageView = UIImageView() + + var onZoom: ((Bool) -> Void)? + var onToolBar: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupScrollView() + setupImageView() + setupDoubleTapGesture() + } + + required init?(coder: NSCoder) { + fatalError( "init(coder:) has not been implemented" ) + } + + private func setupScrollView() { + scrollView.delegate = self + scrollView.minimumZoomScale = 1.0 + scrollView.maximumZoomScale = 4.0 + scrollView.bouncesZoom = true + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.frame = contentView.bounds + addSubview(scrollView) + } + + private func setupImageView() { + imageView.contentMode = .scaleAspectFit + imageView.frame = scrollView.bounds + imageView.isUserInteractionEnabled = true + scrollView.addSubview(imageView) + } + + private func setupDoubleTapGesture() { + let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTapGesture.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTapGesture) + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap)) + singleTapGesture.require(toFail: doubleTapGesture) + scrollView.addGestureRecognizer(singleTapGesture) + } + + func setImage(url: URL) { + let request = ImageRequest(url: url) + + ImagePipeline.shared.loadImage(with: request) { result in + switch result { + case .success(let response): + Task { @MainActor in + self.imageView.image = response.image + } + case .failure: + Task { @MainActor in + print("Error loading image") + } + } + } + } + + func setZoom(isZoomed: Bool) { + UIView.animate(withDuration: 0.3) { + self.onZoom?(true) + self.scrollView.setZoomScale(isZoomed ? 4.0 : 1.0, animated: true) + } + } + + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView = gesture.view as? UIScrollView else { return } + let touchPoint = gesture.location(in: imageView) + if scrollView.zoomScale == scrollView.minimumZoomScale { + onZoom?(true) + let zoomRect = zoomRectForScale(scale: scrollView.maximumZoomScale, center: touchPoint) + scrollView.zoom(to: zoomRect, animated: true) + } else { + onZoom?(false) + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } + } + + @objc private func handleSingleTap() { + onToolBar?() + } + + private func zoomRectForScale(scale: CGFloat, center: CGPoint) -> CGRect { + let scrollViewSize = scrollView.bounds.size + let width = scrollViewSize.width / scale + let height = scrollViewSize.height / scale + let x = center.x - (width / 2) + let y = center.y - (height / 2) + return CGRect(x: x, y: y, width: width, height: height) + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + let isZoomed = scrollView.zoomScale > scrollView.minimumZoomScale + onZoom?(isZoomed) + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } +} diff --git a/Modules/Sources/ArticleFeature/Views/Gallery/TabViewGallery.swift b/Modules/Sources/ArticleFeature/Views/Gallery/TabViewGallery.swift new file mode 100644 index 00000000..80a08bc7 --- /dev/null +++ b/Modules/Sources/ArticleFeature/Views/Gallery/TabViewGallery.swift @@ -0,0 +1,229 @@ +// +// TabViewGallery.swift +// ArticleFeature +// +// Created by Виталий Канин on 19.02.2025. +// + +import SwiftUI +import Models +import Nuke +import NukeUI +import SFSafeSymbols +import SharedUI + +// MARK: - TabViewGallery + +struct TabViewGallery: View { + let gallery: [URL] + @Environment(\.dismiss) private var dismiss + @State var selectedImageID: Int + @State private var backgroundOpacity = 1.0 + @State private var isZooming = false + @State private var isTouched = true + @State private var showShareSheet = false + @State private var activityItems: [Any] = [] + @State private var tempFileUrls: [Int: URL] = [:] + + var body: some View { + ZStack { + if isTouched { + withAnimation(.easeInOut) { + VStack { + ToolBarView() + .background(Color.clear) + Spacer() + } + .frame(alignment: .top) + .opacity(backgroundOpacity) + .zIndex(1) + } + } + + VStack { + CustomScrollView( + imageElement: gallery, + selectedIndex: $selectedImageID, + isZooming: $isZooming, + isTouched: $isTouched, + backgroundOpacity: $backgroundOpacity, + onClose: { + dismiss() + }) + .clipShape( + .rect + ) + } + .ignoresSafeArea() + } + .onAppear { + deleteTempFiles() + preloadImage() + } + .sheet(isPresented: $showShareSheet) { + ShareSheet(activityItems: $activityItems) + .presentationDetents([.medium]) + } + .presentationBackgroundClear() + } + + // MARK: - ToolBarView + + @ViewBuilder + private func ToolBarView() -> some View { + HStack { + ToolbarButton(placement: .topBarLeading, symbol: .xmark) { + dismiss() + } + + Spacer() + + Text(gallery.count == 1 ? String("") : String(String(selectedImageID + 1) + "/" + String(gallery.count))) + .foregroundStyle(.white.opacity(backgroundOpacity)) + .font(.headline.weight(.semibold)) + + Spacer() + + Menu { + ContextButton(text: "Save", symbol: .arrowDownToLine, bundle: .module) { + saveImage() + } + + ContextButton(text: "Share", symbol: .squareAndArrowUp, bundle: .module) { + configureShareSheet() + } + + } label: { + ToolbarButton(placement: .topBarTrailing, symbol: .ellipsis, action: {}) + } + } + .padding(.top, 8) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + private func saveImage() { + let request = ImageRequest(url: gallery[selectedImageID]) + + ImagePipeline.shared.loadImage(with: request) { result in + switch result { + case .success(let response): + let image = response.image + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + case .failure(let error): + print("Error: \(error)") + } + } + } + + private func configureShareSheet() { + if let fileURL = tempFileUrls[selectedImageID] { + activityItems = [fileURL] + showShareSheet = true + } else { + print("File Not Found for ID: \(selectedImageID)") + } + } + + private func preloadImage() { + for element in gallery.enumerated() { + let imageUrl = gallery[element.offset] + let request = ImageRequest(url: imageUrl) + ImagePipeline.shared.loadImage(with: request) { result in + switch result { + case .success(let response): + let tempFileUrl = FileManager.default.temporaryDirectory.appendingPathComponent("image\(element.offset + 1).jpg") + do { + if let imageData = response.image.jpegData(compressionQuality: 1.0) { + try imageData.write(to: tempFileUrl) + tempFileUrls[element.offset] = tempFileUrl + } + } catch { + print("Image not loaded: \(error)") + } + case .failure(let error): + print("Image not loaded: \(error)") + } + } + } + } + + private func deleteTempFiles() { + let temporaryDirectory = FileManager.default.temporaryDirectory + do { + let fileURLs = try FileManager.default.contentsOfDirectory(at: temporaryDirectory, includingPropertiesForKeys: nil) + for fileURL in fileURLs { + try FileManager.default.removeItem(at: fileURL) + } + } catch { + print("Error deleting temp files: \(error)") + } + } + + // MARK: - ToolbarButton + + private func ToolbarButton( + placement: ToolbarItemPlacement, + symbol: SFSymbol, + action: @escaping () -> Void + ) -> some View { + Button { + action() + } label: { + Image(systemSymbol: symbol) + .font(.body) + .foregroundStyle(.white) + .scaleEffect(0.8) // TODO: ? + .background( + Circle() + .fill(.ultraThinMaterial.opacity(backgroundOpacity)) + .frame(width: 32, height: 32) + ) + .highPriorityGesture( + TapGesture().onEnded { + dismiss() + } + ) + } + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } +} + +// MARK: - ShareSheet +struct ShareSheet: UIViewControllerRepresentable { + @Binding var activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +extension View { + func presentationBackgroundClear() -> some View { + self.modifier(BackgroundView()) + } +} + +struct BackgroundView: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content + .presentationBackground(.clear) + } else { + content + } + } +} + +// MARK: - Preview + +#Preview { + TabViewGallery(gallery: [ + URL(string: "https://i.4pda.ws/static/img/news/63/633610t.jpg")!, + URL(string: "https://i.4pda.ws/static/img/news/63/633618t.jpg")!, + URL(string: "https://i.4pda.ws/static/img/news/63/633610t.jpg")! + ], + selectedImageID: Int(0)) +} diff --git a/Modules/Sources/Models/Articles/Article.swift b/Modules/Sources/Models/Articles/Article.swift index b63c9b48..2a9d227a 100644 --- a/Modules/Sources/Models/Articles/Article.swift +++ b/Modules/Sources/Models/Articles/Article.swift @@ -74,8 +74,19 @@ public extension Article { commentsAmount: 69, imageUrl: URL(string: "https://i.4pda.ws/s/Zy0hTlz0vbyz2C0NqwmGqhAbhbvNX1nQXZBLeBHoOUajz2n.jpg?v=1719840424")!, title: "Enim amet excepteur consectetur quis velit id labore eiusmod.", - description: "Occaecat enim duis dolor tempor nostrud ea veniam culpa magna incididunt nisi ut laborum amet.\n\n Игру можно [url=\"https://store.epicgames.com/ru/p/fist-forged-in-shadow-torch\"]забрать бесплатно[/url] до 1 августа. \n\n [quote] «Шесть лет назад Легион захватил и колонизировал город Светоч.\n\n [/quote]\n[center][attachment=\"1:dummy\"][/center]\n\n[center][youtube=eOqif3M_UFY:640:360:::0][/center]\n\n[list]\t[*]41 мм, GPS — $249\n\t[*]41 мм, LTE (или 5G) — $299\n\t[*]45 мм, GPS — $279\n\t[*]45 мм, LTE (или 5G) — $329\n [/list]\n", - attachments: [Attachment(id: 1, smallUrl: URL(string: "https://4pda.to/static/img/news/60/601868t.jpg")!, width: 480, height: 270, description: "", fullUrl: URL(string: "https://4pda.to/static/img/news/60/601868.jpg")!)], + description: "Occaecat enim duis dolor tempor nostrud ea veniam culpa magna incididunt nisi ut laborum amet.\n\n Игру можно [url=\"https://store.epicgames.com/ru/p/fist-forged-in-shadow-torch\"]забрать бесплатно[/url] до 1 августа. \n\n [quote] «Шесть лет назад Легион захватил и колонизировал город Светоч.\n\n [/quote]\n[center][attachment=\"1:dummy\"][/center]\n\n[center][attachment=\"1:dummy\"] [spoiler=\"ещё 9 фотографий\"][attachment=\"2:dummy\"] [attachment=\"3:dummy\"] [attachment=\"4:dummy\"] [attachment=\"5:dummy\"] [attachment=\"6:dummy\"] [attachment=\"7:dummy\"] [attachment=\"8:dummy\"] [attachment=\"9:dummy\"] [attachment=\"10:dummy\"] [/spoiler][/center]\n\n[center][youtube=eOqif3M_UFY:640:360:::0][/center]\n\n[list]\t[*]41 мм, GPS — $249\n\t[*]41 мм, LTE (или 5G) — $299\n\t[*]45 мм, GPS — $279\n\t[*]45 мм, LTE (или 5G) — $329\n [/list]\n", + attachments: [ + Attachment(id: 1, smallUrl: URL(string: "https://4pda.to/static/img/news/60/601868t.jpg")!, width: 480, height: 269, description: "", fullUrl: URL(string: "https://4pda.to/static/img/news/60/601868.jpg")!), + Attachment(id: 2, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633614t.jpg")!, width: 480, height: 269, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633614t.jpg")!), + Attachment(id: 3, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633619t.jpg")!, width: 480, height: 269, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633619.jpg")!), + Attachment(id: 4, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633611t.jpg")!, width: 480, height: 269, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633611.jpg")!), + Attachment(id: 5, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633612t.jpg")!, width: 480, height: 269, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633612.jpg")!), + Attachment(id: 6, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633615t.jpg")!, width: 480, height: 269, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633615.jpg")!), + Attachment(id: 7, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633617t.jpg")!, width: 480, height: 270, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633617.jpg")!), + Attachment(id: 8, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633618t.jpg")!, width: 480, height: 270, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633618.jpg")!), + Attachment(id: 9, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633616t.jpg")!, width: 480, height: 270, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633616.jpg")!), + Attachment(id: 10, smallUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633613t.jpg")!, width: 480, height: 270, description: "", fullUrl: URL(string: "https://i.4pda.ws/static/img/news/63/633613.jpg")!) + ], tags: [], comments: .mockArray, poll: nil