Skip to content

Commit 5460f51

Browse files
initial image gallery implementation
1 parent e6c51a7 commit 5460f51

File tree

6 files changed

+285
-29
lines changed

6 files changed

+285
-29
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// Copyright © 2021 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
struct GalleryView: View {
9+
10+
var message: ChatMessage
11+
@Binding var isShown: Bool
12+
13+
var body: some View {
14+
GeometryReader { reader in
15+
VStack {
16+
GalleryHeaderView(
17+
title: message.author.name ?? "",
18+
subtitle: message.author.onlineText,
19+
isShown: $isShown
20+
)
21+
22+
TabView {
23+
ForEach(sources, id: \.self) { url in
24+
LazyLoadingImage(
25+
source: url,
26+
width: reader.size.width,
27+
resize: true
28+
)
29+
.frame(width: reader.size.width)
30+
.aspectRatio(contentMode: .fit)
31+
}
32+
}
33+
.tabViewStyle(.page)
34+
.indexViewStyle(.page(backgroundDisplayMode: .never))
35+
}
36+
}
37+
}
38+
39+
private var sources: [URL] {
40+
message.imageAttachments.map { attachment in
41+
attachment.imageURL
42+
}
43+
}
44+
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoPlayerView.swift renamed to Sources/StreamChatSwiftUI/ChatChannel/Gallery/VideoPlayerView.swift

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ public struct VideoPlayerView: View {
1818

1919
private let avPlayer: AVPlayer
2020

21-
init(attachment: ChatMessageVideoAttachment, author: ChatUser, isShown: Binding<Bool>) {
21+
init(
22+
attachment: ChatMessageVideoAttachment,
23+
author: ChatUser,
24+
isShown: Binding<Bool>
25+
) {
2226
self.attachment = attachment
2327
self.author = author
2428
avPlayer = AVPlayer(url: attachment.payload.videoURL)
@@ -27,37 +31,24 @@ public struct VideoPlayerView: View {
2731

2832
public var body: some View {
2933
VStack {
30-
ZStack {
31-
HStack {
32-
Button {
33-
isShown = false
34-
} label: {
35-
Image(systemName: "xmark")
36-
}
37-
.padding()
38-
.foregroundColor(Color(colors.text))
39-
40-
Spacer()
41-
}
42-
43-
VStack {
44-
Text(author.name ?? "")
45-
.font(fonts.bodyBold)
46-
Text(onlineInfoText)
47-
.font(fonts.footnote)
48-
.foregroundColor(Color(colors.textLowEmphasis))
49-
}
50-
}
34+
GalleryHeaderView(
35+
title: author.name ?? "",
36+
subtitle: author.onlineText,
37+
isShown: $isShown
38+
)
5139
VideoPlayer(player: avPlayer)
5240
Spacer()
5341
}
5442
.onAppear {
5543
avPlayer.play()
5644
}
5745
}
46+
}
47+
48+
extension ChatUser {
5849

59-
private var onlineInfoText: String {
60-
if author.isOnline {
50+
var onlineText: String {
51+
if isOnline {
6152
return L10n.Message.Title.online
6253
} else {
6354
return L10n.Message.Title.offline
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//
2+
// Copyright © 2021 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Combine
6+
import SwiftUI
7+
8+
struct ZoomableScrollView<Content: View>: View {
9+
let content: Content
10+
11+
init(@ViewBuilder content: () -> Content) {
12+
self.content = content()
13+
}
14+
15+
@State var doubleTap = PassthroughSubject<Void, Never>()
16+
17+
var body: some View {
18+
ZoomableScrollViewImpl(
19+
content: content,
20+
doubleTap: doubleTap.eraseToAnyPublisher()
21+
)
22+
.onTapGesture(count: 2) {
23+
doubleTap.send()
24+
}
25+
}
26+
}
27+
28+
private struct ZoomableScrollViewImpl<Content: View>: UIViewControllerRepresentable {
29+
let content: Content
30+
let doubleTap: AnyPublisher<Void, Never>
31+
32+
func makeUIViewController(context: Context) -> ZoomableScrollViewController {
33+
ZoomableScrollViewController(coordinator: context.coordinator, doubleTap: doubleTap)
34+
}
35+
36+
func makeCoordinator() -> Coordinator {
37+
Coordinator(hostingController: UIHostingController(rootView: content))
38+
}
39+
40+
func updateUIViewController(_ viewController: ZoomableScrollViewController, context: Context) {
41+
viewController.update(content: content, doubleTap: doubleTap)
42+
}
43+
44+
// MARK: - ZoomableScrollViewController
45+
46+
class ZoomableScrollViewController: UIViewController, UIScrollViewDelegate {
47+
let coordinator: Coordinator
48+
let scrollView = UIScrollView()
49+
50+
var doubleTapCancellable: Cancellable?
51+
var updateConstraintsCancellable: Cancellable?
52+
53+
private var hostedView: UIView { coordinator.hostingController.view! }
54+
55+
private var contentSizeConstraints: [NSLayoutConstraint] = [] {
56+
willSet { NSLayoutConstraint.deactivate(contentSizeConstraints) }
57+
didSet { NSLayoutConstraint.activate(contentSizeConstraints) }
58+
}
59+
60+
@available(*, unavailable)
61+
required init?(coder: NSCoder) { fatalError() }
62+
init(coordinator: Coordinator, doubleTap: AnyPublisher<Void, Never>) {
63+
self.coordinator = coordinator
64+
super.init(nibName: nil, bundle: nil)
65+
view = scrollView
66+
67+
scrollView.delegate = self
68+
scrollView.maximumZoomScale = 10
69+
scrollView.minimumZoomScale = 1
70+
scrollView.bouncesZoom = true
71+
scrollView.showsHorizontalScrollIndicator = false
72+
scrollView.showsVerticalScrollIndicator = false
73+
scrollView.clipsToBounds = false
74+
75+
let hostedView = coordinator.hostingController.view!
76+
hostedView.translatesAutoresizingMaskIntoConstraints = false
77+
scrollView.addSubview(hostedView)
78+
NSLayoutConstraint.activate([
79+
hostedView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
80+
hostedView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
81+
hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
82+
hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor)
83+
])
84+
85+
updateConstraintsCancellable = scrollView.publisher(for: \.bounds).map(\.size).removeDuplicates()
86+
.sink { [unowned self] _ in
87+
view.setNeedsUpdateConstraints()
88+
}
89+
doubleTapCancellable = doubleTap.sink { [unowned self] in handleDoubleTap() }
90+
}
91+
92+
func update(content: Content, doubleTap: AnyPublisher<Void, Never>) {
93+
coordinator.hostingController.rootView = content
94+
scrollView.setNeedsUpdateConstraints()
95+
doubleTapCancellable = doubleTap.sink { [unowned self] in handleDoubleTap() }
96+
}
97+
98+
func handleDoubleTap() {
99+
scrollView.setZoomScale(
100+
scrollView.zoomScale > 1 ? scrollView.minimumZoomScale : 2,
101+
animated: true
102+
)
103+
}
104+
105+
override func updateViewConstraints() {
106+
super.updateViewConstraints()
107+
let hostedContentSize = coordinator.hostingController.sizeThatFits(in: view.bounds.size)
108+
contentSizeConstraints = [
109+
hostedView.widthAnchor.constraint(equalToConstant: hostedContentSize.width),
110+
hostedView.heightAnchor.constraint(equalToConstant: hostedContentSize.height)
111+
]
112+
}
113+
114+
override func viewDidAppear(_ animated: Bool) {
115+
scrollView.zoom(to: hostedView.bounds, animated: false)
116+
}
117+
118+
override func viewDidLayoutSubviews() {
119+
super.viewDidLayoutSubviews()
120+
121+
let hostedContentSize = coordinator.hostingController.sizeThatFits(in: view.bounds.size)
122+
scrollView.minimumZoomScale = min(
123+
scrollView.bounds.width / hostedContentSize.width,
124+
scrollView.bounds.height / hostedContentSize.height
125+
)
126+
}
127+
128+
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
129+
coordinator.animate(alongsideTransition: { _ in
130+
self.scrollView.zoom(to: self.hostedView.bounds, animated: false)
131+
})
132+
}
133+
134+
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
135+
hostedView
136+
}
137+
}
138+
139+
// MARK: - Coordinator
140+
141+
class Coordinator: NSObject, UIScrollViewDelegate {
142+
var hostingController: UIHostingController<Content>
143+
144+
init(hostingController: UIHostingController<Content>) {
145+
self.hostingController = hostingController
146+
}
147+
}
148+
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/ImageAttachmentView.swift

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public struct ImageAttachmentContainer: View {
1414
let width: CGFloat
1515
let isFirst: Bool
1616
@Binding var scrolledId: String?
17+
18+
@State private var galleryShown = false
1719

1820
public var body: some View {
1921
VStack(
@@ -32,10 +34,14 @@ public struct ImageAttachmentContainer: View {
3234
alignment: message.alignmentInBubble,
3335
spacing: 0
3436
) {
35-
ImageAttachmentView(
36-
message: message,
37-
width: width
38-
)
37+
Button {
38+
self.galleryShown = true
39+
} label: {
40+
ImageAttachmentView(
41+
message: message,
42+
width: width
43+
)
44+
}
3945

4046
if !message.text.isEmpty {
4147
HStack {
@@ -49,6 +55,12 @@ public struct ImageAttachmentContainer: View {
4955
.clipped()
5056
}
5157
.messageBubble(for: message, isFirst: isFirst)
58+
.fullScreenCover(isPresented: $galleryShown) {
59+
GalleryView(
60+
message: message,
61+
isShown: $galleryShown
62+
)
63+
}
5264
}
5365

5466
private var backgroundColor: UIColor {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// Copyright © 2021 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
struct GalleryHeaderView: View {
9+
10+
@Injected(\.colors) private var colors
11+
@Injected(\.fonts) private var fonts
12+
13+
var title: String
14+
var subtitle: String
15+
16+
@Binding var isShown: Bool
17+
18+
var body: some View {
19+
ZStack {
20+
HStack {
21+
Button {
22+
isShown = false
23+
} label: {
24+
Image(systemName: "xmark")
25+
}
26+
.padding()
27+
.foregroundColor(Color(colors.text))
28+
29+
Spacer()
30+
}
31+
32+
VStack {
33+
Text(title)
34+
.font(fonts.bodyBold)
35+
Text(subtitle)
36+
.font(fonts.footnote)
37+
.foregroundColor(Color(colors.textLowEmphasis))
38+
}
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)