Skip to content

Commit 6e55f53

Browse files
Apple Messages composer example (#383)
1 parent 812472f commit 6e55f53

File tree

2 files changed

+370
-0
lines changed

2 files changed

+370
-0
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
//
2+
// Copyright © 2023 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import StreamChatSwiftUI
7+
import SwiftUI
8+
9+
@available(iOS 15.0, *)
10+
struct AppleMessageComposerView<Factory: ViewFactory>: View, KeyboardReadable {
11+
12+
@State var text = ""
13+
@State var shouldShow = false
14+
15+
@Injected(\.colors) private var colors
16+
@Injected(\.fonts) private var fonts
17+
18+
// Initial popup size, before the keyboard is shown.
19+
@State private var popupSize: CGFloat = 350
20+
@State private var composerHeight: CGFloat = 0
21+
@State private var keyboardShown = false
22+
@State private var editedMessageWillShow = false
23+
24+
private var factory: Factory
25+
private var channelConfig: ChannelConfig?
26+
@Binding var quotedMessage: ChatMessage?
27+
@Binding var editedMessage: ChatMessage?
28+
29+
@State private var state: AnimationState = .initial
30+
@State private var listScale: CGFloat = 0
31+
32+
public init(
33+
viewFactory: Factory,
34+
viewModel: MessageComposerViewModel? = nil,
35+
channelController: ChatChannelController,
36+
messageController: ChatMessageController? = nil,
37+
quotedMessage: Binding<ChatMessage?>,
38+
editedMessage: Binding<ChatMessage?>,
39+
onMessageSent: @escaping () -> Void
40+
) {
41+
factory = viewFactory
42+
channelConfig = channelController.channel?.config
43+
let vm = viewModel ?? ViewModelsFactory.makeMessageComposerViewModel(
44+
with: channelController,
45+
messageController: messageController
46+
)
47+
_viewModel = StateObject(
48+
wrappedValue: vm
49+
)
50+
_quotedMessage = quotedMessage
51+
_editedMessage = editedMessage
52+
self.onMessageSent = onMessageSent
53+
}
54+
55+
@StateObject var viewModel: MessageComposerViewModel
56+
57+
var onMessageSent: () -> Void
58+
59+
var body: some View {
60+
VStack(spacing: 0) {
61+
HStack(alignment: .bottom) {
62+
Button {
63+
withAnimation(.bouncy) {
64+
switch state {
65+
case .initial:
66+
listScale = 1
67+
state = .expanded
68+
case .expanded:
69+
listScale = 0
70+
state = .initial
71+
}
72+
}
73+
} label: {
74+
Image(systemName: "plus")
75+
.padding(.all, 8)
76+
.foregroundColor(Color.gray)
77+
.background(Color(colors.background1))
78+
.clipShape(Circle())
79+
}
80+
.padding(.bottom, 4)
81+
82+
ComposerInputView(
83+
factory: DefaultViewFactory.shared,
84+
text: $viewModel.text,
85+
selectedRangeLocation: $viewModel.selectedRangeLocation,
86+
command: $viewModel.composerCommand,
87+
addedAssets: viewModel.addedAssets,
88+
addedFileURLs: viewModel.addedFileURLs,
89+
addedCustomAttachments: viewModel.addedCustomAttachments,
90+
quotedMessage: $quotedMessage,
91+
maxMessageLength: channelConfig?.maxMessageLength,
92+
cooldownDuration: viewModel.cooldownDuration,
93+
onCustomAttachmentTap: viewModel.customAttachmentTapped(_:),
94+
removeAttachmentWithId: viewModel.removeAttachment(with:)
95+
)
96+
.overlay(
97+
viewModel.sendButtonEnabled ? sendButton : nil
98+
)
99+
}
100+
.padding(.all, 8)
101+
102+
factory.makeAttachmentPickerView(
103+
attachmentPickerState: $viewModel.pickerState,
104+
filePickerShown: $viewModel.filePickerShown,
105+
cameraPickerShown: $viewModel.cameraPickerShown,
106+
addedFileURLs: $viewModel.addedFileURLs,
107+
onPickerStateChange: viewModel.change(pickerState:),
108+
photoLibraryAssets: viewModel.imageAssets,
109+
onAssetTap: viewModel.imageTapped(_:),
110+
onCustomAttachmentTap: viewModel.customAttachmentTapped(_:),
111+
isAssetSelected: viewModel.isImageSelected(with:),
112+
addedCustomAttachments: viewModel.addedCustomAttachments,
113+
cameraImageAdded: viewModel.cameraImageAdded(_:),
114+
askForAssetsAccessPermissions: viewModel.askForPhotosPermission,
115+
isDisplayed: viewModel.overlayShown,
116+
height: viewModel.overlayShown ? popupSize : 0,
117+
popupHeight: popupSize
118+
)
119+
}
120+
.background(
121+
GeometryReader { proxy in
122+
let frame = proxy.frame(in: .local)
123+
let height = frame.height
124+
Color.clear.preference(key: HeightPreferenceKey.self, value: height)
125+
}
126+
)
127+
.onPreferenceChange(HeightPreferenceKey.self) { value in
128+
if let value = value, value != composerHeight {
129+
self.composerHeight = value
130+
}
131+
}
132+
.onReceive(keyboardWillChangePublisher) { visible in
133+
if visible && !keyboardShown {
134+
if viewModel.composerCommand == nil && !editedMessageWillShow {
135+
withAnimation(.easeInOut(duration: 0.02)) {
136+
viewModel.pickerTypeState = .expanded(.none)
137+
}
138+
}
139+
}
140+
keyboardShown = visible
141+
editedMessageWillShow = false
142+
}
143+
.onReceive(keyboardHeight) { height in
144+
if height > 0 && height != popupSize {
145+
self.popupSize = height - bottomSafeArea
146+
}
147+
}
148+
.overlay(
149+
viewModel.showCommandsOverlay ?
150+
factory.makeCommandsContainerView(
151+
suggestions: viewModel.suggestions,
152+
handleCommand: { commandInfo in
153+
viewModel.handleCommand(
154+
for: $viewModel.text,
155+
selectedRangeLocation: $viewModel.selectedRangeLocation,
156+
command: $viewModel.composerCommand,
157+
extraData: commandInfo
158+
)
159+
}
160+
)
161+
.offset(y: -composerHeight)
162+
.animation(nil) : nil,
163+
alignment: .bottom
164+
)
165+
.modifier(factory.makeComposerViewModifier())
166+
.onChange(of: editedMessage) { _ in
167+
viewModel.text = editedMessage?.text ?? ""
168+
if editedMessage != nil {
169+
editedMessageWillShow = true
170+
viewModel.selectedRangeLocation = editedMessage?.text.count ?? 0
171+
}
172+
}
173+
.accessibilityElement(children: .contain)
174+
.overlay(
175+
ComposerActionsView(viewModel: viewModel, state: $state, listScale: $listScale)
176+
.offset(y: -(UIScreen.main.bounds.height - composerHeight) / 2 + 80)
177+
.allowsHitTesting(state == .expanded)
178+
)
179+
}
180+
181+
private var sendButton: some View {
182+
BottomRightView {
183+
Button {
184+
viewModel.sendMessage(quotedMessage: nil, editedMessage: nil) {
185+
onMessageSent()
186+
}
187+
} label: {
188+
Image(systemName: "arrow.up.circle.fill")
189+
.resizable()
190+
.aspectRatio(contentMode: .fit)
191+
.frame(width: 24)
192+
.foregroundColor(.blue)
193+
}
194+
.padding(.trailing, 4)
195+
.padding(.bottom, !viewModel.addedAssets.isEmpty ? 16 : 8)
196+
}
197+
}
198+
}
199+
200+
@available(iOS 15.0, *)
201+
struct BlurredBackground: View {
202+
var body: some View {
203+
Color.clear
204+
.frame(
205+
width: UIScreen.main.bounds.width,
206+
height: UIScreen.main.bounds.height
207+
)
208+
.background(
209+
.ultraThinMaterial,
210+
in: RoundedRectangle(cornerRadius: 16.0)
211+
)
212+
}
213+
}
214+
215+
struct HeightPreferenceKey: PreferenceKey {
216+
static var defaultValue: CGFloat? = nil
217+
218+
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
219+
value = value ?? nextValue()
220+
}
221+
}
222+
223+
enum AnimationState {
224+
case initial, expanded
225+
}
226+
227+
struct ComposerAction: Equatable, Identifiable {
228+
static func == (lhs: ComposerAction, rhs: ComposerAction) -> Bool {
229+
lhs.id == rhs.id
230+
}
231+
232+
var imageName: String
233+
var text: String
234+
var color: Color
235+
var action: () -> Void
236+
var id: String {
237+
"\(imageName)-\(text)"
238+
}
239+
}
240+
241+
@available(iOS 15.0, *)
242+
struct ComposerActionsView: View {
243+
244+
@ObservedObject var viewModel: MessageComposerViewModel
245+
246+
@State var composerActions: [ComposerAction] = []
247+
248+
@Binding var state: AnimationState
249+
@Binding var listScale: CGFloat
250+
251+
var body: some View {
252+
ZStack(alignment: .bottomLeading) {
253+
Color.white.opacity(state == .initial ? 0.2 : 0.5)
254+
255+
BlurredBackground()
256+
.opacity(state == .initial ? 0.0 : 1)
257+
258+
VStack(alignment: .leading, spacing: 30) {
259+
ForEach(composerActions) { composerAction in
260+
Button {
261+
withAnimation {
262+
state = .initial
263+
composerAction.action()
264+
}
265+
} label: {
266+
ComposerActionView(composerAction: composerAction)
267+
}
268+
}
269+
}
270+
.padding(.leading, 40)
271+
.padding(.bottom, 84)
272+
.scaleEffect(
273+
CGSize(
274+
width: state == .initial ? 0 : 1,
275+
height: state == .initial ? 0 : 1
276+
)
277+
)
278+
.offset(
279+
x: state == .initial ? -75 : 0,
280+
y: state == .initial ? 90 : 0
281+
)
282+
}
283+
.onAppear {
284+
setupComposerActions()
285+
}
286+
.edgesIgnoringSafeArea(.all)
287+
.onTapGesture {
288+
withAnimation(.bouncy) {
289+
switch state {
290+
case .initial:
291+
listScale = 1
292+
state = .expanded
293+
case .expanded:
294+
listScale = 0
295+
state = .initial
296+
}
297+
}
298+
}
299+
}
300+
301+
private func setupComposerActions() {
302+
let imageAction: () -> Void = {
303+
viewModel.pickerTypeState = .expanded(.media)
304+
viewModel.pickerState = .photos
305+
}
306+
let commandsAction: () -> Void = {
307+
viewModel.pickerTypeState = .expanded(.instantCommands)
308+
}
309+
let filesAction: () -> Void = {
310+
viewModel.pickerTypeState = .expanded(.media)
311+
viewModel.pickerState = .files
312+
}
313+
let cameraAction: () -> Void = {
314+
viewModel.pickerTypeState = .expanded(.media)
315+
viewModel.pickerState = .camera
316+
}
317+
318+
composerActions = [
319+
ComposerAction(
320+
imageName: "photo.on.rectangle",
321+
text: "Photos",
322+
color: .purple,
323+
action: imageAction
324+
),
325+
ComposerAction(
326+
imageName: "camera.circle.fill",
327+
text: "Camera",
328+
color: .gray,
329+
action: cameraAction
330+
),
331+
ComposerAction(
332+
imageName: "folder.circle",
333+
text: "Files",
334+
color: .indigo,
335+
action: filesAction
336+
),
337+
ComposerAction(
338+
imageName: "command.circle.fill",
339+
text: "Commands",
340+
color: .orange,
341+
action: commandsAction
342+
)
343+
]
344+
}
345+
}
346+
347+
struct ComposerActionView: View {
348+
349+
private let imageSize: CGFloat = 34
350+
351+
var composerAction: ComposerAction
352+
353+
var body: some View {
354+
HStack(spacing: 20) {
355+
Image(systemName: composerAction.imageName)
356+
.resizable()
357+
.scaledToFit()
358+
.foregroundColor(composerAction.color)
359+
.frame(width: imageSize, height: imageSize)
360+
361+
Text(composerAction.text)
362+
.foregroundColor(.primary)
363+
.font(.title2)
364+
}
365+
}
366+
}

StreamChatSwiftUI.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
844EF8ED2809AACD00CC82F9 /* NoContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844EF8EC2809AACD00CC82F9 /* NoContentView.swift */; };
166166
84507C98281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84507C97281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift */; };
167167
84507C9A281ACCD70081DDC2 /* AddUsersView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84507C99281ACCD70081DDC2 /* AddUsersView_Tests.swift */; };
168+
8451617E2AE7B093000A9230 /* AppleMessageComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */; };
168169
8463D9262836617F002B1894 /* ChannelListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8463D9252836617F002B1894 /* ChannelListPage.swift */; };
169170
8465FBBE2746873A00AF091E /* StreamChatSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; };
170171
8465FCBF27468B6900AF091E /* DemoAppSwiftUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8465FCBE27468B6900AF091E /* DemoAppSwiftUIApp.swift */; };
@@ -667,6 +668,7 @@
667668
844EF8EC2809AACD00CC82F9 /* NoContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoContentView.swift; sourceTree = "<group>"; };
668669
84507C97281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersViewModel_Tests.swift; sourceTree = "<group>"; };
669670
84507C99281ACCD70081DDC2 /* AddUsersView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersView_Tests.swift; sourceTree = "<group>"; };
671+
8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMessageComposerView.swift; sourceTree = "<group>"; };
670672
8463D9252836617F002B1894 /* ChannelListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListPage.swift; sourceTree = "<group>"; };
671673
8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StreamChatSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
672674
8465FBBD2746873A00AF091E /* StreamChatSwiftUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StreamChatSwiftUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -1417,6 +1419,7 @@
14171419
8465FDDC2747A14700AF091E /* CustomComposerAttachmentView.swift */,
14181420
84335013274BAB15007A1B81 /* ViewFactoryExamples.swift */,
14191421
84FF723E2782FB2E006E26C8 /* iMessagePocView.swift */,
1422+
8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */,
14201423
8417AE912ADEDB6400445021 /* UserRepository.swift */,
14211424
84EDBC36274FE5CD0057218D /* Localizable.strings */,
14221425
8465FCCA27468B7500AF091E /* Info.plist */,
@@ -2672,6 +2675,7 @@
26722675
84B288D1274CEDD000DD090B /* GroupNameView.swift in Sources */,
26732676
84335014274BAB15007A1B81 /* ViewFactoryExamples.swift in Sources */,
26742677
8465FCDE274694D200AF091E /* CustomChannelHeader.swift in Sources */,
2678+
8451617E2AE7B093000A9230 /* AppleMessageComposerView.swift in Sources */,
26752679
84B288D3274D23AF00DD090B /* LoginView.swift in Sources */,
26762680
84B288D5274D286500DD090B /* LoginViewModel.swift in Sources */,
26772681
8465FCBF27468B6900AF091E /* DemoAppSwiftUIApp.swift in Sources */,

0 commit comments

Comments
 (0)