Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add image gallery to articles #41

Merged
merged 8 commits into from
Apr 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions ForPDA.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2839,6 +2842,7 @@
67177EBF73A3EAA0B80AEE24 /* TuistFonts+CacheClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+CacheClient.swift"; sourceTree = "<group>"; };
6721D235B3FA36225B3C8144 /* TuistAssets+BookmarksFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistAssets+BookmarksFeature.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
6764C3EB2945A53038BCE307 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
67B79800FEE68FB8EA1B8F40 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
69D06482712444A605E2B9C6 /* TopicBuilder.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TopicBuilder.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -2881,6 +2885,7 @@
80EED22D45233E216DFED0EA /* TuistAssets+ArticleFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistAssets+ArticleFeature.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScrollView.swift; sourceTree = "<group>"; };
82DB60D4B9083EC073BFC07C /* FavoritesRootEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesRootEvent.swift; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -2959,6 +2964,7 @@
B06C779165D24CB727795AEB /* TuistFonts+NotificationsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+NotificationsFeature.swift"; sourceTree = "<group>"; };
B28971DBCF658964A52E32E7 /* ArticleElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleElementView.swift; sourceTree = "<group>"; };
B2A7090197422BB1A461D825 /* BBAttributedTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BBAttributedTokenizer.swift; sourceTree = "<group>"; };
B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewGallery.swift; sourceTree = "<group>"; };
B487A0FA81FA470EE3027064 /* TopicFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicFeature.swift; sourceTree = "<group>"; };
B4EC2971DC481D53169646D1 /* TuistFonts+NotificationsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+NotificationsClient.swift"; sourceTree = "<group>"; };
B55C906394BB2ECF59F0CEC9 /* ToastInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastInfo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3853,6 +3859,16 @@
path = Analytics;
sourceTree = "<group>";
};
18109C296EB581D582CE1747 /* Gallery */ = {
isa = PBXGroup;
children = (
81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */,
67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */,
B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */,
);
path = Gallery;
sourceTree = "<group>";
};
1DB9BCAE34C7291DA91CE3BE /* Models */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -4948,6 +4964,7 @@
BD12CB093819B7C6DC7B1FE3 /* Views */ = {
isa = PBXGroup;
children = (
18109C296EB581D582CE1747 /* Gallery */,
B28971DBCF658964A52E32E7 /* ArticleElementView.swift */,
101DA82283F6CEA438667EB7 /* ArticleMenu.swift */,
4DA58394E7006E74B711137B /* ParallaxHeader.swift */,
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,26 @@
}
}
},
"Save" : {
"localizations" : {
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Сохранить"
}
}
}
},
"Share" : {
"localizations" : {
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Поделиться"
}
}
}
},
"Share Link" : {
"localizations" : {
"ru" : {
Expand Down
19 changes: 18 additions & 1 deletion Modules/Sources/ArticleFeature/Views/ArticleElementView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct ArticleElementView: View {
@State private var gallerySelection: Int = 0
@State private var pollSelection: ArticlePoll.Option?
@State private var pollSelections: Set<ArticlePoll.Option> = .init()
@State private var showFullScreenGallery = false
@State private var selectedImageID = 0

private var hasSelection: Bool {
return pollSelection != nil || !pollSelections.isEmpty
Expand Down Expand Up @@ -113,14 +115,20 @@ 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

@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 {
Expand All @@ -133,13 +141,22 @@ struct ArticleElementView: View {
}
.aspectRatio(imageElement.ratioWH, contentMode: .fit)
.clipped()
.highPriorityGesture(
TapGesture().onEnded {
showFullScreenGallery.toggle()
selectedImageID = index
}
)
}
.padding(.bottom, 48) // Fix against index overlaying
}
.frame(height: CGFloat(element.max(by: { $0.ratioHW < $1.ratioHW})!.ratioHW) * UIScreen.main.bounds.width + 48)
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.padding(.bottom, -16)
.fullScreenCover(isPresented: $showFullScreenGallery) {
TabViewGallery(gallery: element.map{ $0.url }, selectedImageID: selectedImageID)
}
}

// MARK: - Video
Expand Down
181 changes: 181 additions & 0 deletions Modules/Sources/ArticleFeature/Views/Gallery/CustomScrollView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading