Move composer send media selection to it's own coordinator and us e ioS 15 api for sheet

This commit is contained in:
David Langley
2022-10-08 10:46:05 +01:00
parent 02929684c7
commit 9645cf939e
13 changed files with 395 additions and 235 deletions
@@ -1,159 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import UIKit
// 1 - Create a UISheetPresentationController that can be used in a SwiftUI interface
@available(iOS 15.0, *)
struct SheetPresentationForSwiftUI<Content>: UIViewRepresentable where Content: View {
@Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let detents: [UISheetPresentationController.Detent]
let content: Content
init(
_ isPresented: Binding<Bool>,
onDismiss: (() -> Void)? = nil,
detents: [UISheetPresentationController.Detent] = [.medium()],
@ViewBuilder content: () -> Content
) {
self._isPresented = isPresented
self.onDismiss = onDismiss
self.detents = detents
self.content = content()
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// Create the UIViewController that will be presented by the UIButton
let viewController = UIViewController()
// Create the UIHostingController that will embed the SwiftUI View
let hostingController = UIHostingController(rootView: content)
// Add the UIHostingController to the UIViewController
viewController.addChild(hostingController)
viewController.view.addSubview(hostingController.view)
// Set constraints
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.leftAnchor.constraint(equalTo: viewController.view.leftAnchor).isActive = true
hostingController.view.topAnchor.constraint(equalTo: viewController.view.topAnchor).isActive = true
hostingController.view.rightAnchor.constraint(equalTo: viewController.view.rightAnchor).isActive = true
hostingController.view.bottomAnchor.constraint(equalTo: viewController.view.bottomAnchor).isActive = true
hostingController.didMove(toParent: viewController)
// Set the presentationController as a UISheetPresentationController
if let sheetController = viewController.presentationController as? UISheetPresentationController {
sheetController.detents = detents
sheetController.prefersGrabberVisible = true
sheetController.prefersScrollingExpandsWhenScrolledToEdge = false
}
// Set the coordinator (delegate)
// We need the delegate to use the presentationControllerDidDismiss function
viewController.presentationController?.delegate = context.coordinator
if isPresented {
// Present the viewController
uiView.window?.rootViewController?.present(viewController, animated: true)
} else {
// Dismiss the viewController
uiView.window?.rootViewController?.dismiss(animated: true, completion: onDismiss)
}
}
/* Creates the custom instance that you use to communicate changes
from your view controller to other parts of your SwiftUI interface.
*/
func makeCoordinator() -> Coordinator {
Coordinator(isPresented: $isPresented, onDismiss: onDismiss)
}
class Coordinator: NSObject, UISheetPresentationControllerDelegate {
@Binding var isPresented: Bool
let onDismiss: (() -> Void)?
init(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil) {
self._isPresented = isPresented
self.onDismiss = onDismiss
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
isPresented = false
if let onDismiss = onDismiss {
onDismiss()
}
}
}
}
// 2 - Create the SwiftUI modifier conforming to the ViewModifier protocol
@available(iOS 15.0, *)
struct sheetWithDetentsViewModifier<SwiftUIContent>: ViewModifier where SwiftUIContent: View {
@Binding var isPresented: Bool
let onDismiss: (() -> Void)?
let detents: [UISheetPresentationController.Detent]
let swiftUIContent: SwiftUIContent
init(isPresented: Binding<Bool>, detents: [UISheetPresentationController.Detent] = [.medium()] , onDismiss: (() -> Void)? = nil, content: () -> SwiftUIContent) {
self._isPresented = isPresented
self.onDismiss = onDismiss
self.swiftUIContent = content()
self.detents = detents
}
func body(content: Content) -> some View {
ZStack {
SheetPresentationForSwiftUI($isPresented,onDismiss: onDismiss, detents: detents) {
swiftUIContent
}.fixedSize()
content
}
}
}
// 3 - Create extension on View that makes it easier to use the custom modifier
extension View {
@available(iOS 15.0, *)
func sheetWithDetents<Content>(
isPresented: Binding<Bool>,
detents: [UISheetPresentationController.Detent],
onDismiss: (() -> Void)?,
content: @escaping () -> Content) -> some View where Content : View {
modifier(
sheetWithDetentsViewModifier(
isPresented: isPresented,
detents: detents,
onDismiss: onDismiss,
content: content)
)
}
}
@@ -31,7 +31,7 @@ enum FormatType {
case underline
}
@objc enum ComposerModule: Int {
@objc enum ComposerCreateAction: Int {
case photoLibrary
case stickers
case attachments
@@ -105,11 +105,11 @@ extension FormatType {
}
}
extension ComposerModule: CaseIterable, Identifiable {
extension ComposerCreateAction: CaseIterable, Identifiable {
var id: Self { self }
}
extension ComposerModule {
extension ComposerCreateAction {
var title: String {
switch self {
case .photoLibrary:
@@ -144,3 +144,12 @@ extension ComposerModule {
}
}
}
struct ComposerCreateActionListViewState: BindableState {
let actions: [ComposerCreateAction]
}
enum ComposerCreateActionListViewModelResult {
case done(ComposerCreateAction)
}
@@ -0,0 +1,89 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Foundation
@objc protocol ComposerCreateActionListBridgePresenterDelegate {
func composerCreateActionListBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter, action: ComposerCreateAction)
func composerCreateActionListBridgePresenterDidDismissInteractively(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter)
}
/// ComposerCreateActionListBridgePresenter enables to start ComposerCreateActionList from a view controller.
/// This bridge is used while waiting for global usage of coordinator pattern.
/// **WARNING**: This class breaks the Coordinator abstraction and it has been introduced for **Objective-C compatibility only**
/// (mainly for integration in legacy view controllers). Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator.
@objcMembers
final class ComposerCreateActionListBridgePresenter: NSObject {
// MARK: - Constants
// MARK: - Properties
// MARK: Private
private let actions: [ComposerCreateAction]
private var coordinator: ComposerCreateActionListCoordinator?
// MARK: Public
weak var delegate: ComposerCreateActionListBridgePresenterDelegate?
// MARK: - Setup
init(actions: [Int]) {
self.actions = actions.compactMap({ ComposerCreateAction(rawValue: $0) })
super.init()
}
// MARK: - Public
// NOTE: Default value feature is not compatible with Objective-C.
// func present(from viewController: UIViewController, animated: Bool) {
// self.present(from: viewController, animated: animated)
// }
func present(from viewController: UIViewController, animated: Bool) {
let composerCreateActionListCoordinator = ComposerCreateActionListCoordinator(actions: actions)
composerCreateActionListCoordinator.callback = { [weak self] action in
guard let self = self else { return }
switch action {
case .done(let composeAction):
self.delegate?.composerCreateActionListBridgePresenterDelegateDidComplete(self, action: composeAction)
case .cancel:
self.delegate?.composerCreateActionListBridgePresenterDidDismissInteractively(self)
}
}
let presentable = composerCreateActionListCoordinator.toPresentable()
viewController.present(presentable, animated: animated, completion: nil)
composerCreateActionListCoordinator.start()
self.coordinator = composerCreateActionListCoordinator
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
guard let coordinator = self.coordinator else {
return
}
// Dismiss modal
coordinator.toPresentable().dismiss(animated: animated) {
self.coordinator = nil
if let completion = completion {
completion()
}
}
}
}
@@ -0,0 +1,71 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
/// Actions returned by the coordinator callback
enum ComposerCreateActionListCoordinatorAction {
case done(ComposerCreateAction)
case cancel
}
final class ComposerCreateActionListCoordinator: NSObject, Coordinator, Presentable, UISheetPresentationControllerDelegate {
// MARK: - Properties
// MARK: Private
private let hostingController: UIViewController
private var view: ComposerCreateActionList
private var viewModel: ComposerCreateActionListViewModel
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((ComposerCreateActionListCoordinatorAction) -> Void)?
// MARK: - Setup
init(actions: [ComposerCreateAction]) {
viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions))
self.view = ComposerCreateActionList(viewModel: viewModel.context)
let hostingVC = VectorHostingController(rootView: view)
hostingVC.bottomSheetPreferences = VectorHostingBottomSheetPreferences(detents: [.medium])
hostingController = hostingVC
super.init()
hostingVC.presentationController?.delegate = self
}
// MARK: - Public
func start() {
MXLog.debug("[ComposerCreateActionListCoordinator] did start.")
viewModel.callback = { result in
switch result {
case .done(let action):
self.callback?(.done(action))
}
}
}
func toPresentable() -> UIViewController {
return self.hostingController
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
self.callback?(.cancel)
}
}
@@ -0,0 +1,59 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct ComposerCreateActionList: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@ObservedObject var viewModel: ComposerCreateActionListViewModel.Context
var body: some View {
VStack{
VStack(alignment: .leading) {
ForEach(viewModel.viewState.actions) { action in
HStack(spacing: 16) {
Image(action.icon)
.renderingMode(.template)
.foregroundColor(theme.colors.accent)
Text(action.title)
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.body)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.send(viewAction: .selectAction(action))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.padding(.top, 16)
Spacer()
}.background(theme.colors.background.ignoresSafeArea())
}
}
struct ComposerCreateActionList_Previews: PreviewProvider {
static let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: ComposerCreateAction.allCases))
static var previews: some View {
ComposerCreateActionList(viewModel: viewModel.context)
}
}
@@ -0,0 +1,22 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol ComposerCreateActionListViewModelProtocol {
var callback: ((ComposerCreateActionListViewModelResult) -> Void)? { get set }
var context: ComposerCreateActionListViewModelType.Context { get }
}
@@ -0,0 +1,44 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias ComposerCreateActionListViewModelType = StateStoreViewModel<ComposerCreateActionListViewState,
Never,
ComposerCreateActionListViewAction>
class ComposerCreateActionListViewModel: ComposerCreateActionListViewModelType, ComposerCreateActionListViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((ComposerCreateActionListViewModelResult) -> Void)?
// MARK: - Setup
// MARK: - Public
override func process(viewAction: ComposerCreateActionListViewAction) {
switch viewAction {
case .selectAction(let action):
callback?(.done(action))
}
}
}
@@ -18,7 +18,7 @@ import Foundation
import WysiwygComposer
import SwiftUI
@available(iOS 16.0, *)
@available(iOS 15.0, *)
enum MockComposerScreenState: MockScreenState, CaseIterable {
case composer
@@ -39,7 +39,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
[viewModel],
AnyView(VStack{
Spacer()
Composer(viewModel: viewModel, sendMessageAction: { _ in }, startModuleAction: { _ in })
Composer(viewModel: viewModel, sendMessageAction: { _ in }, showSendMediaActions: { })
}.frame(
minWidth: 0,
maxWidth: .infinity,
@@ -18,16 +18,16 @@ import SwiftUI
import WysiwygComposer
import DSBottomSheet
@available(iOS 16.0, *)
@available(iOS 15.0, *)
struct Composer: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
@ObservedObject var viewModel: WysiwygComposerViewModel
let sendMessageAction: (WysiwygComposerContent) -> Void
let startModuleAction: (ComposerModule) -> Void
@State private var isBottomSheetExpanded = false
let showSendMediaActions: () -> Void
var textColor = Color(.label)
@State private var showSendButton = false
private let borderHeight: CGFloat = 44
@@ -81,7 +81,7 @@ struct Composer: View {
.padding(.bottom, 4)
HStack{
Button {
isBottomSheetExpanded = true
showSendMediaActions()
} label: {
Image(Asset.Images.startComposeModule.name)
.foregroundColor(theme.colors.tertiaryContent)
@@ -117,44 +117,19 @@ struct Composer: View {
.padding(.horizontal, 16)
.padding(.bottom, 4)
}
.sheet(isPresented: $isBottomSheetExpanded) {
moduleSelectionList
.presentationDetents([.medium])
}
}
var moduleSelectionList: some View {
VStack {
VStack(alignment: .leading) {
ForEach(ComposerModule.allCases) { module in
HStack(spacing: 16) {
Image(module.icon)
.renderingMode(.template)
.foregroundColor(theme.colors.accent)
Text(module.title)
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.body)
Spacer()
}
.onTapGesture {
isBottomSheetExpanded = false
self.startModuleAction(module)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.padding(.top, 16)
.background(theme.colors.background.ignoresSafeArea())
Spacer()
}
}
}
@available(iOS 16.0, *)
@available(iOS 15.0, *)
struct Composer_Previews: PreviewProvider {
static let stateRenderer = MockComposerScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}
enum ComposerCreateActionListViewAction {
case selectAction(ComposerCreateAction)
}