mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 15:22:39 +00:00
Display user suggestion list in fullscreen mode with shared context from UserSuggestionCoordinator
This commit is contained in:
parent
88aac572cc
commit
5fb426f772
10 changed files with 148 additions and 36 deletions
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -52,6 +52,10 @@ final class UserSuggestionCoordinatorBridge: NSObject {
|
|||
func toPresentable() -> UIViewController? {
|
||||
userSuggestionCoordinator.toPresentable()
|
||||
}
|
||||
|
||||
func sharedContext() -> UserSuggestionSharedContext {
|
||||
userSuggestionCoordinator.sharedContext()
|
||||
}
|
||||
}
|
||||
|
||||
extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate {
|
||||
|
|
|
@ -28,6 +28,10 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
|
|||
|
||||
// MARK: Public
|
||||
|
||||
var sharedContext: UserSuggestionViewModelType.Context {
|
||||
return self.context
|
||||
}
|
||||
|
||||
var completion: ((UserSuggestionViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
|
|
@ -17,5 +17,6 @@
|
|||
import Foundation
|
||||
|
||||
protocol UserSuggestionViewModelProtocol {
|
||||
var sharedContext: UserSuggestionViewModelType.Context { get }
|
||||
var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue