Skip to content

Commit 30485a7

Browse files
authored
Merge pull request #41 from SubvertDev/feature/image-gallery
Add image gallery to articles
2 parents 5af6b56 + 91a239f commit 30485a7

File tree

7 files changed

+598
-3
lines changed

7 files changed

+598
-3
lines changed

ForPDA.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@
353353
66FC76ADFBEF155B4AD5684E /* SortType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124DA2BF5DC419EB71D063EE /* SortType.swift */; };
354354
6757DAF4542B89F4E49037F5 /* SFSafeSymbols.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 322E22C74D44156FAE5F3F7B /* SFSafeSymbols.framework */; };
355355
67A6B4599E52C547BA1DF046 /* FavoriteRootFeature+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E759700E6C2878F605BBEC0 /* FavoriteRootFeature+Analytics.swift */; };
356+
67BA9AB34F23890684D33638 /* TabViewGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */; };
356357
67DCC37FFEA96AA2B7892715 /* ComposableArchitecture.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F42D2398CAA67D4000962E67 /* ComposableArchitecture.framework */; };
357358
681BDDE56CBE8E4014DFE5E3 /* NotificationsFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 006917E2DFC70599AF512EA1 /* NotificationsFeature.framework */; };
358359
68DF677F404827BB69F6F0FE /* Models.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD51C6E965A35DBF9F4FBA55 /* Models.framework */; };
@@ -676,6 +677,7 @@
676677
C8C174A7838AA886FEC1D7D6 /* Models.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD51C6E965A35DBF9F4FBA55 /* Models.framework */; };
677678
C9BB21B7E3604B43D7945AAE /* ArticleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99F23D68A39F53CF4566F1A /* ArticleParser.swift */; };
678679
CA65F52046767C63F6B38B83 /* LoginEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FD9D2DDE325720FC78D6A0 /* LoginEvent.swift */; };
680+
CA8BBC8E71A6DF343FCE120A /* ImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */; };
679681
CAA93B384FC9055B705904F7 /* ArticlesListFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A39BD5202EED5434C358B34E /* ArticlesListFeature.framework */; };
680682
CABBF3C4FAF86859FA053854 /* content.js in Resources */ = {isa = PBXBuildFile; fileRef = 98A2BB020C3E971CB0991A5E /* content.js */; };
681683
CABDE1660FCCBA352BD96785 /* NotificationsClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 889505F119E7D81F1A1B9E41 /* NotificationsClient.framework */; };
@@ -747,6 +749,7 @@
747749
DE4D4F43FA68F91A454AC76F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 63C087325941C6CC8EDB1EA8 /* Preview Assets.xcassets */; };
748750
DEC0DE86920768F3141234FE /* CombineSchedulers.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 94F0AD33CFEC2D542AD5BC7C /* CombineSchedulers.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
749751
DED7880C6B8C32503A0D4FBA /* ArticlePoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048460D126E2C26FEB6265DC /* ArticlePoll.swift */; };
752+
DEEC77E32E9C1693FD3C2BC1 /* CustomScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */; };
750753
DF4551A19CB85081DE3D30EB /* Cache_Cache.bundle in Dependencies */ = {isa = PBXBuildFile; fileRef = 4BAC16218EC9F9A9F2999ABB /* Cache_Cache.bundle */; };
751754
DFD97D366B0725ADD3EFF5E6 /* HistoryFeature.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02A126A4634AB82351345379 /* HistoryFeature.framework */; };
752755
DFE1FC541AF185F6935349F3 /* ForumRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F2AB27D0AE7F79E68C49FF /* ForumRow.swift */; };
@@ -2839,6 +2842,7 @@
28392842
67177EBF73A3EAA0B80AEE24 /* TuistFonts+CacheClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+CacheClient.swift"; sourceTree = "<group>"; };
28402843
6721D235B3FA36225B3C8144 /* TuistAssets+BookmarksFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistAssets+BookmarksFeature.swift"; sourceTree = "<group>"; };
28412844
67482FDA32E710F193B122C5 /* DeveloperFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DeveloperFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2845+
67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCollectionViewCell.swift; sourceTree = "<group>"; };
28422846
6764C3EB2945A53038BCE307 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
28432847
67B79800FEE68FB8EA1B8F40 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
28442848
69D06482712444A605E2B9C6 /* TopicBuilder.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TopicBuilder.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -2881,6 +2885,7 @@
28812885
80EED22D45233E216DFED0EA /* TuistAssets+ArticleFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistAssets+ArticleFeature.swift"; sourceTree = "<group>"; };
28822886
812721A72ADCFCDF77E41819 /* QMSListFeature.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = QMSListFeature.framework; sourceTree = BUILT_PRODUCTS_DIR; };
28832887
819C3F42D1C0667301523468 /* HistoryFeature-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "HistoryFeature-Info.plist"; sourceTree = "<group>"; };
2888+
81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScrollView.swift; sourceTree = "<group>"; };
28842889
82DB60D4B9083EC073BFC07C /* FavoritesRootEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesRootEvent.swift; sourceTree = "<group>"; };
28852890
83FB4761086E2397F004D41A /* SwiftyGif_SwiftyGif.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftyGif_SwiftyGif.bundle; sourceTree = BUILT_PRODUCTS_DIR; };
28862891
85CCC7B42618527254A75999 /* FavoritesFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesFeature.swift; sourceTree = "<group>"; };
@@ -2959,6 +2964,7 @@
29592964
B06C779165D24CB727795AEB /* TuistFonts+NotificationsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+NotificationsFeature.swift"; sourceTree = "<group>"; };
29602965
B28971DBCF658964A52E32E7 /* ArticleElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleElementView.swift; sourceTree = "<group>"; };
29612966
B2A7090197422BB1A461D825 /* BBAttributedTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BBAttributedTokenizer.swift; sourceTree = "<group>"; };
2967+
B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewGallery.swift; sourceTree = "<group>"; };
29622968
B487A0FA81FA470EE3027064 /* TopicFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicFeature.swift; sourceTree = "<group>"; };
29632969
B4EC2971DC481D53169646D1 /* TuistFonts+NotificationsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TuistFonts+NotificationsClient.swift"; sourceTree = "<group>"; };
29642970
B55C906394BB2ECF59F0CEC9 /* ToastInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastInfo.swift; sourceTree = "<group>"; };
@@ -3853,6 +3859,16 @@
38533859
path = Analytics;
38543860
sourceTree = "<group>";
38553861
};
3862+
18109C296EB581D582CE1747 /* Gallery */ = {
3863+
isa = PBXGroup;
3864+
children = (
3865+
81A389182FA02B9BAB9FAE0B /* CustomScrollView.swift */,
3866+
67862053B6EBC0E277C54CB2 /* ImageCollectionViewCell.swift */,
3867+
B38A5F8365A1E77E822F6C62 /* TabViewGallery.swift */,
3868+
);
3869+
path = Gallery;
3870+
sourceTree = "<group>";
3871+
};
38563872
1DB9BCAE34C7291DA91CE3BE /* Models */ = {
38573873
isa = PBXGroup;
38583874
children = (
@@ -4948,6 +4964,7 @@
49484964
BD12CB093819B7C6DC7B1FE3 /* Views */ = {
49494965
isa = PBXGroup;
49504966
children = (
4967+
18109C296EB581D582CE1747 /* Gallery */,
49514968
B28971DBCF658964A52E32E7 /* ArticleElementView.swift */,
49524969
101DA82283F6CEA438667EB7 /* ArticleMenu.swift */,
49534970
4DA58394E7006E74B711137B /* ParallaxHeader.swift */,
@@ -7389,6 +7406,9 @@
73897406
29DE94F1CF7D57B0C72C8948 /* ArticleMenuAction.swift in Sources */,
73907407
5FF953A9137820F5DBE72EEB /* ArticleElementView.swift in Sources */,
73917408
875B98063EE3978A99CF7680 /* ArticleMenu.swift in Sources */,
7409+
DEEC77E32E9C1693FD3C2BC1 /* CustomScrollView.swift in Sources */,
7410+
CA8BBC8E71A6DF343FCE120A /* ImageCollectionViewCell.swift in Sources */,
7411+
67BA9AB34F23890684D33638 /* TabViewGallery.swift in Sources */,
73927412
B5DB8BDE49B18806E1E790BA /* ParallaxHeader.swift in Sources */,
73937413
);
73947414
runOnlyForDeploymentPostprocessing = 0;

Modules/Sources/ArticleFeature/Resources/Localizable.xcstrings

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,26 @@
163163
}
164164
}
165165
},
166+
"Save" : {
167+
"localizations" : {
168+
"ru" : {
169+
"stringUnit" : {
170+
"state" : "translated",
171+
"value" : "Сохранить"
172+
}
173+
}
174+
}
175+
},
176+
"Share" : {
177+
"localizations" : {
178+
"ru" : {
179+
"stringUnit" : {
180+
"state" : "translated",
181+
"value" : "Поделиться"
182+
}
183+
}
184+
}
185+
},
166186
"Share Link" : {
167187
"localizations" : {
168188
"ru" : {

Modules/Sources/ArticleFeature/Views/ArticleElementView.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ struct ArticleElementView: View {
2020
@State private var gallerySelection: Int = 0
2121
@State private var pollSelection: ArticlePoll.Option?
2222
@State private var pollSelections: Set<ArticlePoll.Option> = .init()
23+
@State private var showFullScreenGallery = false
24+
@State private var selectedImageID = 0
2325

2426
private var hasSelection: Bool {
2527
return pollSelection != nil || !pollSelections.isEmpty
@@ -113,14 +115,20 @@ struct ArticleElementView: View {
113115
.frame(width: UIScreen.main.bounds.width,
114116
height: UIScreen.main.bounds.width * element.ratioHW)
115117
.clipped()
118+
.onTapGesture {
119+
showFullScreenGallery.toggle()
120+
}
121+
.fullScreenCover(isPresented: $showFullScreenGallery) {
122+
TabViewGallery(gallery: [element.url], selectedImageID: selectedImageID)
123+
}
116124
}
117125

118126
// MARK: - Gallery
119127

120128
@ViewBuilder
121129
private func gallery(_ element: [ImageElement]) -> some View {
122130
TabView {
123-
ForEach(element, id: \.self) { imageElement in
131+
ForEach(Array(element.enumerated()), id: \.element) { index, imageElement in
124132
LazyImage(url: imageElement.url) { state in
125133
Group {
126134
if let image = state.image {
@@ -133,13 +141,22 @@ struct ArticleElementView: View {
133141
}
134142
.aspectRatio(imageElement.ratioWH, contentMode: .fit)
135143
.clipped()
144+
.highPriorityGesture(
145+
TapGesture().onEnded {
146+
showFullScreenGallery.toggle()
147+
selectedImageID = index
148+
}
149+
)
136150
}
137151
.padding(.bottom, 48) // Fix against index overlaying
138152
}
139153
.frame(height: CGFloat(element.max(by: { $0.ratioHW < $1.ratioHW})!.ratioHW) * UIScreen.main.bounds.width + 48)
140154
.tabViewStyle(.page(indexDisplayMode: .always))
141155
.indexViewStyle(.page(backgroundDisplayMode: .always))
142156
.padding(.bottom, -16)
157+
.fullScreenCover(isPresented: $showFullScreenGallery) {
158+
TabViewGallery(gallery: element.map{ $0.url }, selectedImageID: selectedImageID)
159+
}
143160
}
144161

145162
// MARK: - Video
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
//
2+
// CustomScrollView.swift
3+
// ArticleFeature
4+
//
5+
// Created by Виталий Канин on 11.03.2025.
6+
//
7+
8+
import SwiftUI
9+
import Models
10+
11+
struct CustomScrollView: UIViewRepresentable {
12+
13+
let imageElement: [URL]
14+
@Binding var selectedIndex: Int
15+
@Binding var isZooming: Bool
16+
@Binding var isTouched: Bool
17+
@Binding var backgroundOpacity: Double
18+
var onClose: (() -> Void)?
19+
20+
func makeUIView(context: Context) -> UICollectionView {
21+
22+
let layout = UICollectionViewFlowLayout()
23+
layout.scrollDirection = .horizontal
24+
layout.minimumLineSpacing = 0
25+
layout.itemSize = UIScreen.main.bounds.size
26+
27+
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
28+
collectionView.isPagingEnabled = true
29+
collectionView.showsHorizontalScrollIndicator = false
30+
collectionView.showsVerticalScrollIndicator = false
31+
collectionView.backgroundColor = .clear
32+
collectionView.dataSource = context.coordinator
33+
collectionView.delegate = context.coordinator
34+
collectionView.backgroundColor = .black
35+
collectionView.register(ImageCollectionViewCell.self, forCellWithReuseIdentifier: "ImageCollectionViewCell")
36+
37+
let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleVerticalSwipe(_:)))
38+
panGesture.delegate = context.coordinator as any UIGestureRecognizerDelegate
39+
collectionView.addGestureRecognizer(panGesture)
40+
41+
return collectionView
42+
}
43+
44+
func updateUIView(_ uiView: UICollectionView, context: Context) {
45+
let indexPath = IndexPath(item: selectedIndex, section: 0)
46+
Task { @MainActor in
47+
uiView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
48+
}
49+
}
50+
51+
func makeCoordinator() -> Coordinator {
52+
Coordinator(self)
53+
}
54+
55+
class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate{
56+
var parent: CustomScrollView
57+
private var initialTouchPoint: CGPoint = .zero
58+
private var firstSwipeDirection: SwipeDirection = .none
59+
60+
enum SwipeDirection {
61+
case horizontal
62+
case vertical
63+
case none
64+
}
65+
66+
init(_ parent: CustomScrollView) {
67+
self.parent = parent
68+
}
69+
70+
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
71+
return parent.imageElement.count
72+
}
73+
74+
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
75+
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCollectionViewCell", for: indexPath) as! ImageCollectionViewCell
76+
cell.setImage(url: parent.imageElement[indexPath.item])
77+
78+
cell.onZoom = { isZooming in
79+
Task { @MainActor in
80+
self.parent.isZooming = isZooming
81+
}
82+
}
83+
84+
cell.onToolBar = {
85+
Task { @MainActor in
86+
self.parent.isTouched.toggle()
87+
}
88+
}
89+
return cell
90+
}
91+
92+
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
93+
let pageIndex = Int(scrollView.contentOffset.x / scrollView.bounds.width)
94+
self.parent.selectedIndex = pageIndex
95+
96+
// We always have a last gesture
97+
if let gestureRecognizers = scrollView.gestureRecognizers, let lastGesture = gestureRecognizers.last {
98+
lastGesture.isEnabled = true
99+
}
100+
101+
firstSwipeDirection = .none
102+
}
103+
104+
@objc func handleVerticalSwipe(_ gesture: UIPanGestureRecognizer) {
105+
guard let collectionView = gesture.view as? UICollectionView else { return }
106+
guard let visibleCell = collectionView.visibleCells.first as? ImageCollectionViewCell else { return }
107+
let translation = gesture.translation(in: gesture.view?.superview)
108+
if parent.isZooming { return }
109+
110+
switch gesture.state {
111+
case .began:
112+
initialTouchPoint = gesture.location(in: gesture.view?.superview)
113+
case .changed:
114+
if abs(translation.y) > abs(translation.x) && firstSwipeDirection == .vertical {
115+
collectionView.isScrollEnabled = false
116+
visibleCell.transform = CGAffineTransform(translationX: 0, y: translation.y)
117+
self.parent.backgroundOpacity = max(0.1, 1 - Double(abs(translation.y * 5) / 700))
118+
collectionView.layer.opacity = max(0.1, 1 - Float(abs(translation.y * 2.5) / 700))
119+
}
120+
case .ended, .cancelled:
121+
if abs(translation.y) > 150 {
122+
parent.onClose?()
123+
UIView.animate(withDuration: 0.6,
124+
delay: 0,
125+
usingSpringWithDamping: 0.8,
126+
initialSpringVelocity: 0.3,
127+
options: .curveEaseInOut,
128+
animations: {
129+
self.parent.backgroundOpacity = 0.0
130+
collectionView.layer.opacity = 0.0
131+
})
132+
} else {
133+
UIView.animate(withDuration: 0.6,
134+
delay: 0,
135+
usingSpringWithDamping: 0.8,
136+
initialSpringVelocity: 0.3,
137+
options: .curveEaseInOut,
138+
animations: {
139+
visibleCell.transform = CGAffineTransform(translationX: 0, y: 0)
140+
self.parent.backgroundOpacity = 1.0
141+
collectionView.layer.opacity = 1.0
142+
})
143+
}
144+
firstSwipeDirection = .none
145+
collectionView.isScrollEnabled = true
146+
case .failed, .possible:
147+
break
148+
@unknown default:
149+
break
150+
}
151+
}
152+
153+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
154+
guard let collection = otherGestureRecognizer.view as? UICollectionView else {
155+
return false
156+
}
157+
158+
if parent.imageElement.count == 1 {
159+
firstSwipeDirection = .vertical
160+
return true
161+
}
162+
163+
if let panGesture = gestureRecognizer as? UIPanGestureRecognizer {
164+
let velocity = panGesture.velocity(in: panGesture.view)
165+
if firstSwipeDirection == .none {
166+
if abs(velocity.x) > abs(velocity.y) {
167+
firstSwipeDirection = .horizontal
168+
collection.isScrollEnabled = true
169+
panGesture.isEnabled = false //
170+
} else if abs(velocity.x) < abs(velocity.y) {
171+
firstSwipeDirection = .vertical
172+
otherGestureRecognizer.isEnabled = true
173+
collection.isScrollEnabled = false
174+
}
175+
}
176+
}
177+
178+
return true
179+
}
180+
}
181+
}

0 commit comments

Comments
 (0)