Skip to content

Commit

Permalink
Apple Messages composer example (#383)
Browse files Browse the repository at this point in the history
  • Loading branch information
martinmitrevski authored Oct 24, 2023
1 parent 812472f commit 6e55f53
Show file tree
Hide file tree
Showing 2 changed files with 370 additions and 0 deletions.
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

0 comments on commit 6e55f53

Please sign in to comment.