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

Apple Messages composer example #383

Merged
merged 6 commits into from
Oct 24, 2023
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
366 changes: 366 additions & 0 deletions DemoAppSwiftUI/AppleMessageComposerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
//
// Copyright © 2023 Stream.io Inc. All rights reserved.
//

import StreamChat
import StreamChatSwiftUI
import SwiftUI

@available(iOS 15.0, *)
struct AppleMessageComposerView<Factory: ViewFactory>: View, KeyboardReadable {

@State var text = ""
@State var shouldShow = false

@Injected(\.colors) private var colors
@Injected(\.fonts) private var fonts

// Initial popup size, before the keyboard is shown.
@State private var popupSize: CGFloat = 350
@State private var composerHeight: CGFloat = 0
@State private var keyboardShown = false
@State private var editedMessageWillShow = false

private var factory: Factory
private var channelConfig: ChannelConfig?
@Binding var quotedMessage: ChatMessage?
@Binding var editedMessage: ChatMessage?

@State private var state: AnimationState = .initial
@State private var listScale: CGFloat = 0

public init(
viewFactory: Factory,
viewModel: MessageComposerViewModel? = nil,
channelController: ChatChannelController,
messageController: ChatMessageController? = nil,
quotedMessage: Binding<ChatMessage?>,
editedMessage: Binding<ChatMessage?>,
onMessageSent: @escaping () -> Void
) {
factory = viewFactory
channelConfig = channelController.channel?.config
let vm = viewModel ?? ViewModelsFactory.makeMessageComposerViewModel(
with: channelController,
messageController: messageController
)
_viewModel = StateObject(
wrappedValue: vm
)
_quotedMessage = quotedMessage
_editedMessage = editedMessage
self.onMessageSent = onMessageSent
}

@StateObject var viewModel: MessageComposerViewModel

var onMessageSent: () -> Void

var body: some View {
VStack(spacing: 0) {
HStack(alignment: .bottom) {
Button {
withAnimation(.bouncy) {
switch state {
case .initial:
listScale = 1
state = .expanded
case .expanded:
listScale = 0
state = .initial
}
}
} label: {
Image(systemName: "plus")
.padding(.all, 8)
.foregroundColor(Color.gray)
.background(Color(colors.background1))
.clipShape(Circle())
}
.padding(.bottom, 4)

ComposerInputView(
factory: DefaultViewFactory.shared,
text: $viewModel.text,
selectedRangeLocation: $viewModel.selectedRangeLocation,
command: $viewModel.composerCommand,
addedAssets: viewModel.addedAssets,
addedFileURLs: viewModel.addedFileURLs,
addedCustomAttachments: viewModel.addedCustomAttachments,
quotedMessage: $quotedMessage,
maxMessageLength: channelConfig?.maxMessageLength,
cooldownDuration: viewModel.cooldownDuration,
onCustomAttachmentTap: viewModel.customAttachmentTapped(_:),
removeAttachmentWithId: viewModel.removeAttachment(with:)
)
.overlay(
viewModel.sendButtonEnabled ? sendButton : nil
)
}
.padding(.all, 8)

factory.makeAttachmentPickerView(
attachmentPickerState: $viewModel.pickerState,
filePickerShown: $viewModel.filePickerShown,
cameraPickerShown: $viewModel.cameraPickerShown,
addedFileURLs: $viewModel.addedFileURLs,
onPickerStateChange: viewModel.change(pickerState:),
photoLibraryAssets: viewModel.imageAssets,
onAssetTap: viewModel.imageTapped(_:),
onCustomAttachmentTap: viewModel.customAttachmentTapped(_:),
isAssetSelected: viewModel.isImageSelected(with:),
addedCustomAttachments: viewModel.addedCustomAttachments,
cameraImageAdded: viewModel.cameraImageAdded(_:),
askForAssetsAccessPermissions: viewModel.askForPhotosPermission,
isDisplayed: viewModel.overlayShown,
height: viewModel.overlayShown ? popupSize : 0,
popupHeight: popupSize
)
}
.background(
GeometryReader { proxy in
let frame = proxy.frame(in: .local)
let height = frame.height
Color.clear.preference(key: HeightPreferenceKey.self, value: height)
}
)
.onPreferenceChange(HeightPreferenceKey.self) { value in
if let value = value, value != composerHeight {
self.composerHeight = value
}
}
.onReceive(keyboardWillChangePublisher) { visible in
if visible && !keyboardShown {
if viewModel.composerCommand == nil && !editedMessageWillShow {
withAnimation(.easeInOut(duration: 0.02)) {
viewModel.pickerTypeState = .expanded(.none)
}
}
}
keyboardShown = visible
editedMessageWillShow = false
}
.onReceive(keyboardHeight) { height in
if height > 0 && height != popupSize {
self.popupSize = height - bottomSafeArea
}
}
.overlay(
viewModel.showCommandsOverlay ?
factory.makeCommandsContainerView(
suggestions: viewModel.suggestions,
handleCommand: { commandInfo in
viewModel.handleCommand(
for: $viewModel.text,
selectedRangeLocation: $viewModel.selectedRangeLocation,
command: $viewModel.composerCommand,
extraData: commandInfo
)
}
)
.offset(y: -composerHeight)
.animation(nil) : nil,
alignment: .bottom
)
.modifier(factory.makeComposerViewModifier())
.onChange(of: editedMessage) { _ in
viewModel.text = editedMessage?.text ?? ""
if editedMessage != nil {
editedMessageWillShow = true
viewModel.selectedRangeLocation = editedMessage?.text.count ?? 0
}
}
.accessibilityElement(children: .contain)
.overlay(
ComposerActionsView(viewModel: viewModel, state: $state, listScale: $listScale)
.offset(y: -(UIScreen.main.bounds.height - composerHeight) / 2 + 80)
.allowsHitTesting(state == .expanded)
)
}

private var sendButton: some View {
BottomRightView {
Button {
viewModel.sendMessage(quotedMessage: nil, editedMessage: nil) {
onMessageSent()
}
} label: {
Image(systemName: "arrow.up.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 24)
.foregroundColor(.blue)
}
.padding(.trailing, 4)
.padding(.bottom, !viewModel.addedAssets.isEmpty ? 16 : 8)
}
}
}

@available(iOS 15.0, *)
struct BlurredBackground: View {
var body: some View {
Color.clear
.frame(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height
)
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 16.0)
)
}
}

struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat? = nil

static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}

enum AnimationState {
case initial, expanded
}

struct ComposerAction: Equatable, Identifiable {
static func == (lhs: ComposerAction, rhs: ComposerAction) -> Bool {
lhs.id == rhs.id
}

var imageName: String
var text: String
var color: Color
var action: () -> Void
var id: String {
"\(imageName)-\(text)"
}
}

@available(iOS 15.0, *)
struct ComposerActionsView: View {

@ObservedObject var viewModel: MessageComposerViewModel

@State var composerActions: [ComposerAction] = []

@Binding var state: AnimationState
@Binding var listScale: CGFloat

var body: some View {
ZStack(alignment: .bottomLeading) {
Color.white.opacity(state == .initial ? 0.2 : 0.5)

BlurredBackground()
.opacity(state == .initial ? 0.0 : 1)

VStack(alignment: .leading, spacing: 30) {
ForEach(composerActions) { composerAction in
Button {
withAnimation {
state = .initial
composerAction.action()
}
} label: {
ComposerActionView(composerAction: composerAction)
}
}
}
.padding(.leading, 40)
.padding(.bottom, 84)
.scaleEffect(
CGSize(
width: state == .initial ? 0 : 1,
height: state == .initial ? 0 : 1
)
)
.offset(
x: state == .initial ? -75 : 0,
y: state == .initial ? 90 : 0
)
}
.onAppear {
setupComposerActions()
}
.edgesIgnoringSafeArea(.all)
.onTapGesture {
withAnimation(.bouncy) {
switch state {
case .initial:
listScale = 1
state = .expanded
case .expanded:
listScale = 0
state = .initial
}
}
}
}

private func setupComposerActions() {
let imageAction: () -> Void = {
viewModel.pickerTypeState = .expanded(.media)
viewModel.pickerState = .photos
}
let commandsAction: () -> Void = {
viewModel.pickerTypeState = .expanded(.instantCommands)
}
let filesAction: () -> Void = {
viewModel.pickerTypeState = .expanded(.media)
viewModel.pickerState = .files
}
let cameraAction: () -> Void = {
viewModel.pickerTypeState = .expanded(.media)
viewModel.pickerState = .camera
}

composerActions = [
ComposerAction(
imageName: "photo.on.rectangle",
text: "Photos",
color: .purple,
action: imageAction
),
ComposerAction(
imageName: "camera.circle.fill",
text: "Camera",
color: .gray,
action: cameraAction
),
ComposerAction(
imageName: "folder.circle",
text: "Files",
color: .indigo,
action: filesAction
),
ComposerAction(
imageName: "command.circle.fill",
text: "Commands",
color: .orange,
action: commandsAction
)
]
}
}

struct ComposerActionView: View {

private let imageSize: CGFloat = 34

var composerAction: ComposerAction

var body: some View {
HStack(spacing: 20) {
Image(systemName: composerAction.imageName)
.resizable()
.scaledToFit()
.foregroundColor(composerAction.color)
.frame(width: imageSize, height: imageSize)

Text(composerAction.text)
.foregroundColor(.primary)
.font(.title2)
}
}
}
4 changes: 4 additions & 0 deletions StreamChatSwiftUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
844EF8ED2809AACD00CC82F9 /* NoContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844EF8EC2809AACD00CC82F9 /* NoContentView.swift */; };
84507C98281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84507C97281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift */; };
84507C9A281ACCD70081DDC2 /* AddUsersView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84507C99281ACCD70081DDC2 /* AddUsersView_Tests.swift */; };
8451617E2AE7B093000A9230 /* AppleMessageComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */; };
8463D9262836617F002B1894 /* ChannelListPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8463D9252836617F002B1894 /* ChannelListPage.swift */; };
8465FBBE2746873A00AF091E /* StreamChatSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; };
8465FCBF27468B6900AF091E /* DemoAppSwiftUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8465FCBE27468B6900AF091E /* DemoAppSwiftUIApp.swift */; };
Expand Down Expand Up @@ -667,6 +668,7 @@
844EF8EC2809AACD00CC82F9 /* NoContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoContentView.swift; sourceTree = "<group>"; };
84507C97281AC40F0081DDC2 /* AddUsersViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersViewModel_Tests.swift; sourceTree = "<group>"; };
84507C99281ACCD70081DDC2 /* AddUsersView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersView_Tests.swift; sourceTree = "<group>"; };
8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMessageComposerView.swift; sourceTree = "<group>"; };
8463D9252836617F002B1894 /* ChannelListPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListPage.swift; sourceTree = "<group>"; };
8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StreamChatSwiftUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8465FBBD2746873A00AF091E /* StreamChatSwiftUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StreamChatSwiftUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -1417,6 +1419,7 @@
8465FDDC2747A14700AF091E /* CustomComposerAttachmentView.swift */,
84335013274BAB15007A1B81 /* ViewFactoryExamples.swift */,
84FF723E2782FB2E006E26C8 /* iMessagePocView.swift */,
8451617D2AE7B093000A9230 /* AppleMessageComposerView.swift */,
8417AE912ADEDB6400445021 /* UserRepository.swift */,
84EDBC36274FE5CD0057218D /* Localizable.strings */,
8465FCCA27468B7500AF091E /* Info.plist */,
Expand Down Expand Up @@ -2672,6 +2675,7 @@
84B288D1274CEDD000DD090B /* GroupNameView.swift in Sources */,
84335014274BAB15007A1B81 /* ViewFactoryExamples.swift in Sources */,
8465FCDE274694D200AF091E /* CustomChannelHeader.swift in Sources */,
8451617E2AE7B093000A9230 /* AppleMessageComposerView.swift in Sources */,
84B288D3274D23AF00DD090B /* LoginView.swift in Sources */,
84B288D5274D286500DD090B /* LoginViewModel.swift in Sources */,
8465FCBF27468B6900AF091E /* DemoAppSwiftUIApp.swift in Sources */,
Expand Down
Loading