Display user suggestion list in fullscreen mode with shared context from UserSuggestionCoordinator

This commit is contained in:
aringenbach 2023-03-22 15:49:42 +01:00
parent 88aac572cc
commit 5fb426f772
10 changed files with 148 additions and 36 deletions

View file

@ -5154,6 +5154,11 @@ static CGSize kThreadListBarButtonItemImageSize;
[self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern];
}
- (UserSuggestionSharedContext *)userSuggestionContext
{
return [self.userSuggestionCoordinator sharedContext];
}
- (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView
{
// Consider opening the action menu as beginning to type and share encryption keys if requested.

View file

@ -22,6 +22,7 @@
@class RoomInputToolbarView;
@class LinkActionWrapper;
@class SuggestionPatternWrapper;
@class UserSuggestionSharedContext;
/**
Destination of the message in the composer
@ -83,6 +84,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern;
- (UserSuggestionSharedContext *)userSuggestionContext;
@end
/**

View file

@ -76,7 +76,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
override var delegate: MXKRoomInputToolbarViewDelegate! {
didSet {
wysiwygViewModel.permalinkReplacer = permalinkReplacer
setComposer()
//wysiwygViewModel.permalinkReplacer = permalinkReplacer
}
}
@ -135,6 +136,10 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
wysiwygViewModel.maxCompressedHeight
}
var userSuggestionSharedContext: UserSuggestionSharedContext {
return toolbarViewDelegate!.userSuggestionContext()
}
// MARK: - Setup
override class func instantiate() -> MXKRoomInputToolbarView! {
@ -149,11 +154,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
return (delegate as? PermalinkReplacer)
}
override func awakeFromNib() {
super.awakeFromNib()
func setComposer() {
viewModel = ComposerViewModel(
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting,
isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false)))
isLandscapePhone: isLandscapePhone,
bindings: ComposerBindings(focused: false)))
viewModel.callback = { [weak self] result in
self?.handleViewModelResult(result)
@ -165,6 +170,7 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
let composer = Composer(
viewModel: viewModel.context,
wysiwygViewModel: wysiwygViewModel,
userSuggestionSharedContext: userSuggestionSharedContext,
resizeAnimationDuration: Double(kResizeComposerAnimationDuration),
sendMessageAction: { [weak self] content in
guard let self = self else { return }
@ -246,6 +252,14 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil)
}
override func awakeFromNib() {
super.awakeFromNib()
if delegate != nil {
setComposer()
}
}
override func customizeRendering() {
super.customizeRendering()
self.backgroundColor = .clear

View file

@ -29,12 +29,24 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
var screenView: ([Any], AnyView) {
let viewModel: ComposerViewModel
let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: []))
let userSuggestionSharedContext = UserSuggestionSharedContext(context: userSuggestionViewModel.context,
mediaManager: MXMediaManager())
let bindings = ComposerBindings(focused: false)
switch self {
case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings))
case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings))
case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings))
case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true,
isLandscapePhone: false,
bindings: bindings))
case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit,
textFormattingEnabled: true,
isLandscapePhone: false,
bindings: bindings))
case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser",
sendMode: .reply,
textFormattingEnabled: true,
isLandscapePhone: false,
bindings: bindings))
}
let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360)
@ -57,6 +69,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
Spacer()
Composer(viewModel: viewModel.context,
wysiwygViewModel: wysiwygviewModel,
userSuggestionSharedContext: userSuggestionSharedContext,
resizeAnimationDuration: 0.1,
sendMessageAction: { _ in },
showSendMediaActions: { })
@ -70,3 +83,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
)
}
}
private final class MockUserSuggestionViewModel: UserSuggestionViewModelType {
}

View file

@ -256,3 +256,12 @@ final class SuggestionPatternWrapper: NSObject {
super.init()
}
}
final class UserSuggestionViewModelWrapper: NSObject {
let userSuggestionViewModel: UserSuggestionViewModel
init(_ userSuggestionViewModel: UserSuggestionViewModel) {
self.userSuggestionViewModel = userSuggestionViewModel
super.init()
}
}

View file

@ -23,6 +23,7 @@ struct Composer: View {
// MARK: Private
@ObservedObject private var viewModel: ComposerViewModelType.Context
@ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel
private let userSuggestionSharedContext: UserSuggestionSharedContext
private let resizeAnimationDuration: Double
private let sendMessageAction: (WysiwygComposerContent) -> Void
@ -34,12 +35,39 @@ struct Composer: View {
private let horizontalPadding: CGFloat = 12
private let borderHeight: CGFloat = 40
private var verticalPadding: CGFloat {
private let standardVerticalPadding: CGFloat = 8.0
private let contextBannerHeight: CGFloat = 14.5
/// Spacing applied within the VStack holding the context banner and the composer text view.
private let verticalComponentSpacing: CGFloat = 12.0
/// Padding for the main composer text view. Always applied on bottom.
/// Applied on top only if no context banner is present.
private var composerVerticalPadding: CGFloat {
(borderHeight - wysiwygViewModel.minHeight) / 2
}
private var topPadding: CGFloat {
viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding
/// Computes the top padding to apply on the composer text view depending on context.
private var composerTopPadding: CGFloat {
viewModel.viewState.shouldDisplayContext ? 0 : composerVerticalPadding
}
/// Computes the additional height required to display the context banner.
/// Returns 0.0 if the banner is not displayed.
/// Note: height of the actual banner + its added standard top padding + VStack spacing
private var additionalHeightForContextBanner: CGFloat {
viewModel.viewState.shouldDisplayContext ? contextBannerHeight + standardVerticalPadding + verticalComponentSpacing : 0
}
/// Computes the total height of the composer (excluding the RTE formatting bar).
/// This height includes the text view, as well as the context banner
/// and user suggestion list when displayed.
private var composerHeight: CGFloat {
wysiwygViewModel.idealHeight
+ composerTopPadding
+ composerVerticalPadding
// Extra padding added on top of the VStack containing the composer
+ standardVerticalPadding
+ additionalHeightForContextBanner
}
private var cornerRadius: CGFloat {
@ -84,7 +112,7 @@ struct Composer: View {
private var composerContainer: some View {
let rect = RoundedRectangle(cornerRadius: cornerRadius)
return VStack(spacing: 12) {
return VStack(spacing: verticalComponentSpacing) {
if viewModel.viewState.shouldDisplayContext {
HStack {
if let imageName = viewModel.viewState.contextImageName {
@ -106,7 +134,8 @@ struct Composer: View {
}
.accessibilityIdentifier("cancelButton")
}
.padding(.top, 8)
.frame(height: contextBannerHeight)
.padding(.top, standardVerticalPadding)
.padding(.horizontal, horizontalPadding)
}
HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) {
@ -116,7 +145,6 @@ struct Composer: View {
)
.tintColor(theme.colors.accent)
.placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent)
.frame(height: wysiwygViewModel.idealHeight)
.onAppear {
if wysiwygViewModel.isContentEmpty {
wysiwygViewModel.setup()
@ -137,13 +165,13 @@ struct Composer: View {
}
}
.padding(.horizontal, horizontalPadding)
.padding(.top, topPadding)
.padding(.bottom, verticalPadding)
.padding(.top, composerTopPadding)
.padding(.bottom, composerVerticalPadding)
}
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: 1))
.animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight)
.padding(.top, 8)
.padding(.top, standardVerticalPadding)
.onTapGesture {
if viewModel.focused {
viewModel.focused = true
@ -195,11 +223,13 @@ struct Composer: View {
init(
viewModel: ComposerViewModelType.Context,
wysiwygViewModel: WysiwygComposerViewModel,
userSuggestionSharedContext: UserSuggestionSharedContext,
resizeAnimationDuration: Double,
sendMessageAction: @escaping (WysiwygComposerContent) -> Void,
showSendMediaActions: @escaping () -> Void) {
self.viewModel = viewModel
self.wysiwygViewModel = wysiwygViewModel
self.userSuggestionSharedContext = userSuggestionSharedContext
self.resizeAnimationDuration = resizeAnimationDuration
self.sendMessageAction = sendMessageAction
self.showSendMediaActions = showSendMediaActions
@ -213,6 +243,7 @@ struct Composer: View {
.frame(width: 36, height: 5)
.padding(.top, 10)
}
VStack {
HStack(alignment: .bottom, spacing: 0) {
if !viewModel.viewState.textFormattingEnabled {
sendMediaButton
@ -224,6 +255,12 @@ struct Composer: View {
.padding(.bottom, 1)
}
}
if wysiwygViewModel.maximised {
UserSuggestionList(viewModel: userSuggestionSharedContext.context)
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: userSuggestionSharedContext.mediaManager)))
}
}
.frame(height: composerHeight)
if viewModel.viewState.textFormattingEnabled {
HStack(alignment: .center, spacing: 0) {
sendMediaButton

View file

@ -30,6 +30,19 @@ struct UserSuggestionCoordinatorParameters {
let room: MXRoom
}
/// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple
/// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController`
/// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload data.
final class UserSuggestionSharedContext: NSObject {
let context: UserSuggestionViewModelType.Context
let mediaManager: MXMediaManager
init(context: UserSuggestionViewModelType.Context, mediaManager: MXMediaManager) {
self.context = context
self.mediaManager = mediaManager
}
}
final class UserSuggestionCoordinator: Coordinator, Presentable {
// MARK: - Properties
@ -105,6 +118,11 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
userSuggestionHostingController
}
func sharedContext() -> UserSuggestionSharedContext {
UserSuggestionSharedContext(context: userSuggestionViewModel.sharedContext,
mediaManager: parameters.mediaManager)
}
// MARK: - Private
private func calculateViewHeight() -> CGFloat {

View file

@ -52,6 +52,10 @@ final class UserSuggestionCoordinatorBridge: NSObject {
func toPresentable() -> UIViewController? {
userSuggestionCoordinator.toPresentable()
}
func sharedContext() -> UserSuggestionSharedContext {
userSuggestionCoordinator.sharedContext()
}
}
extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate {

View file

@ -28,6 +28,10 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
// MARK: Public
var sharedContext: UserSuggestionViewModelType.Context {
return self.context
}
var completion: ((UserSuggestionViewModelResult) -> Void)?
// MARK: - Setup

View file

@ -17,5 +17,6 @@
import Foundation
protocol UserSuggestionViewModelProtocol {
var sharedContext: UserSuggestionViewModelType.Context { get }
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
}