Skip to content

Commit 3d74c77

Browse files
committed
Save / share / zoom images
1 parent 8eb1de0 commit 3d74c77

File tree

6 files changed

+157
-15
lines changed

6 files changed

+157
-15
lines changed

App/IcySkyApp.swift

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import DesignSystem
55
import MediaUI
66
import Models
77
import Network
8+
import Nuke
9+
import NukeUI
810
import Router
911
import SwiftUI
1012
import User
@@ -20,6 +22,10 @@ struct IcySkyApp: App {
2022

2123
@Environment(\.scenePhase) var scenePhase
2224

25+
init() {
26+
ImagePipeline.shared = ImagePipeline(configuration: .withDataCache)
27+
}
28+
2329
var body: some Scene {
2430
WindowGroup {
2531
TabView(selection: $router.selectedTab) {

IcySky-Info.plist

+2
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
<dict>
55
<key>ITSAppUsesNonExemptEncryption</key>
66
<false/>
7+
<key>NSPhotoLibraryAddUsageDescription</key>
8+
<string>Save images to your photo library</string>
79
</dict>
810
</plist>
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1+
import DesignSystem
12
import Foundation
3+
import Models
24
import Nuke
35
import NukeUI
46
import SwiftUI
57

68
public struct FullScreenMediaView: View {
7-
let images: [URL]
9+
@Environment(\.dismiss) private var dismiss
10+
11+
let images: [Media]
812
let preloadedImage: URL?
913
let namespace: Namespace.ID
1014

1115
@State private var isFirstImageLoaded: Bool = false
16+
@State private var isOverlayVisible: Bool = false
17+
@State private var isSaved: Bool = false
18+
@State private var scrollPosition: Media?
19+
@State private var isAltVisible: Bool = false
20+
21+
@GestureState private var zoom = 1.0
1222

13-
public init(images: [URL], preloadedImage: URL?, namespace: Namespace.ID) {
23+
public init(images: [Media], preloadedImage: URL?, namespace: Namespace.ID) {
1424
self.images = images
1525
self.preloadedImage = preloadedImage
1626
self.namespace = namespace
@@ -20,39 +30,136 @@ public struct FullScreenMediaView: View {
2030
if let preloadedImage, !isFirstImageLoaded {
2131
return preloadedImage
2232
}
23-
return images.first
33+
return images.first?.url
2434
}
2535

2636
public var body: some View {
27-
ScrollView(.horizontal) {
37+
ScrollView(.horizontal, showsIndicators: false) {
2838
LazyHStack {
2939
ForEach(images.indices, id: \.self) { index in
30-
LazyImage(url: index == 0 ? firstImageURL : images[index]) { state in
40+
LazyImage(
41+
request: .init(
42+
url: index == 0 ? firstImageURL : images[index].url,
43+
priority: .veryHigh)
44+
) { state in
3145
if let image = state.image {
3246
image
3347
.resizable()
3448
.scaledToFill()
3549
.aspectRatio(contentMode: .fit)
50+
.scaleEffect(zoom)
51+
.gesture(
52+
MagnifyGesture()
53+
.updating($zoom) { value, gestureState, transaction in
54+
gestureState = value.magnification
55+
}
56+
)
3657
} else {
3758
RoundedRectangle(cornerRadius: 8)
3859
.fill(.thinMaterial)
3960
}
4061
}
4162
.containerRelativeFrame([.horizontal, .vertical])
63+
.id(images[index])
4264
}
4365
}
4466
.scrollTargetLayout()
4567
}
68+
.scrollPosition(id: $scrollPosition)
69+
.overlay(alignment: .topTrailing) {
70+
topActionsView
71+
}
72+
.overlay(alignment: .bottom) {
73+
bottomActionsView
74+
}
4675
.scrollContentBackground(.hidden)
4776
.scrollTargetBehavior(.viewAligned)
48-
.navigationTransition(.zoom(sourceID: images[0], in: namespace))
77+
.navigationTransition(.zoom(sourceID: images[0].id, in: namespace))
78+
.onTapGesture {
79+
withAnimation {
80+
isOverlayVisible.toggle()
81+
}
82+
}
4983
.task {
84+
scrollPosition = images.first
5085
do {
51-
let data = try await Nuke.ImagePipeline.shared.data(for: .init(url: images.first))
86+
let data = try await ImagePipeline.shared.data(for: .init(url: images.first?.url))
5287
if !data.0.isEmpty {
5388
self.isFirstImageLoaded = true
5489
}
5590
} catch {}
5691
}
5792
}
93+
94+
private var topActionsView: some View {
95+
HStack {
96+
if isOverlayVisible {
97+
Button {
98+
dismiss()
99+
} label: {
100+
Image(systemName: "xmark")
101+
.padding()
102+
}
103+
.buttonStyle(.circle)
104+
.foregroundColor(.indigo)
105+
.padding(.trailing, 16)
106+
.transition(.move(edge: .top).combined(with: .opacity))
107+
}
108+
}
109+
}
110+
111+
private var bottomActionsView: some View {
112+
HStack(spacing: 16) {
113+
if isOverlayVisible {
114+
saveButton
115+
shareButton
116+
}
117+
}
118+
}
119+
120+
private var saveButton: some View {
121+
Button {
122+
Task {
123+
do {
124+
guard let imageURL = scrollPosition?.url else { return }
125+
let data = try await ImagePipeline.shared.data(for: .init(url: imageURL))
126+
if !data.0.isEmpty, let image = UIImage(data: data.0) {
127+
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
128+
withAnimation {
129+
isSaved = true
130+
}
131+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
132+
withAnimation {
133+
isSaved = false
134+
}
135+
}
136+
}
137+
} catch {}
138+
}
139+
} label: {
140+
if isSaved {
141+
Label("Saved", systemImage: "checkmark")
142+
.padding()
143+
} else {
144+
Label("Save", systemImage: "square.and.arrow.down")
145+
.padding()
146+
}
147+
}
148+
.foregroundColor(.indigo)
149+
.buttonStyle(.pill)
150+
.transition(.move(edge: .bottom).combined(with: .opacity))
151+
}
152+
153+
@ViewBuilder
154+
private var shareButton: some View {
155+
if let imageURL = scrollPosition?.url {
156+
ShareLink(item: imageURL) {
157+
Label("Share", systemImage: "square.and.arrow.up")
158+
.padding()
159+
}
160+
.foregroundColor(.indigo)
161+
.buttonStyle(.pill)
162+
.transition(.move(edge: .bottom).combined(with: .opacity))
163+
}
164+
}
58165
}

Packages/Features/Sources/PostUI/Row/PostRowImagesView.swift

+22-7
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,38 @@ struct PostRowImagesView: View {
1616

1717
@State private var firstImageSize: CGSize?
1818
@State private var isMediaExpanded: Bool = false
19+
@State private var shouldRotate = true
1920

2021
var body: some View {
2122
ZStack(alignment: .topLeading) {
2223
ForEach(images.images.indices.reversed(), id: \.self) { index in
2324
makeImageView(image: images.images[index], index: index)
2425
.frame(maxWidth: isQuote ? quoteMaxSize : nil)
2526
.rotationEffect(
26-
index == images.images.indices.first ? .degrees(0) : .degrees(Double(index) * -2),
27+
index == images.images.indices.first
28+
? .degrees(0) : .degrees(shouldRotate ? Double(index) * -2 : 0),
2729
anchor: .bottomTrailing
2830
)
2931
}
3032
}
3133
.padding(.bottom, images.images.count > 1 && !isQuote ? CGFloat(images.images.count) * 7 : 0)
3234
.onTapGesture {
33-
router.presentedSheet = .fullScreenMedia(
34-
images: images.images.map(\.fullSizeImageURL),
35-
preloadedImage: images.images.first?.thumbnailImageURL,
36-
namespace: namespace
37-
)
35+
withAnimation(.easeInOut(duration: 0.1)) {
36+
shouldRotate = false
37+
} completion: {
38+
router.presentedSheet = .fullScreenMedia(
39+
images: images.images.map { .init(url: $0.fullSizeImageURL, alt: $0.altText) },
40+
preloadedImage: images.images.first?.thumbnailImageURL,
41+
namespace: namespace
42+
)
43+
}
44+
}
45+
.onChange(of: router.presentedSheet) {
46+
if router.presentedSheet == nil {
47+
withAnimation(.bouncy) {
48+
shouldRotate = true
49+
}
50+
}
3851
}
3952
}
4053

@@ -54,7 +67,8 @@ struct PostRowImagesView: View {
5467
image
5568
.resizable()
5669
.scaledToFill()
57-
.aspectRatio(contentMode: .fit)
70+
.aspectRatio(contentMode: index == images.images.indices.first ? .fit : .fill)
71+
.clipped()
5872
} else {
5973
RoundedRectangle(cornerRadius: 8)
6074
.fill(.thinMaterial)
@@ -64,6 +78,7 @@ struct PostRowImagesView: View {
6478
.frame(width: finalWidth, height: finalHeight)
6579
.matchedTransitionSource(id: image.fullSizeImageURL, in: namespace)
6680
.glowingRoundedRectangle()
81+
.shadow(color: images.images.count > 1 ? .black.opacity(0.3) : .clear, radius: 3)
6782
.onAppear {
6883
if index == images.images.indices.first {
6984
self.firstImageSize = CGSize(width: displayWidth, height: displayHeight)
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Foundation
2+
3+
public struct Media: Identifiable, Hashable {
4+
public var id: URL { url }
5+
public let url: URL
6+
public let alt: String?
7+
8+
public init(url: URL, alt: String?) {
9+
self.url = url
10+
self.alt = alt?.isEmpty == true ? nil : alt
11+
}
12+
}

Packages/Model/Sources/Router/SheetDestination.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ public enum SheetDestination: Hashable, Identifiable {
66

77
case auth
88
case fullScreenMedia(
9-
images: [URL],
9+
images: [Media],
1010
preloadedImage: URL?,
1111
namespace: Namespace.ID)
1212
}

0 commit comments

Comments
 (0)