mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Rich text editor "expanded mode" (#1656)
* Fix composer icon in dark mode * Add RTE poc * Amend cornerRadius * Add snaps * Fix composer top spacing * Fix clipping * Refine UX * Fix animation * Add constants + iPad hide bars logics * Polish clamping * Fix UT * Cleanup * Add grabber color * Add UI tests * Rename handle -> grabber * Fix resize composer when RTE is off * Fix project file
This commit is contained in:
@@ -27,6 +27,7 @@ internal enum Asset {
|
||||
internal enum Colors {
|
||||
internal static let accentColor = ColorAsset(name: "colors/accent-color")
|
||||
internal static let backgroundColor = ColorAsset(name: "colors/background-color")
|
||||
internal static let grabber = ColorAsset(name: "colors/grabber")
|
||||
}
|
||||
internal enum Images {
|
||||
internal static let appLogo = ImageAsset(name: "images/app-logo")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2023 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.
|
||||
//
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ struct ComposerToolbarViewStateBindings {
|
||||
var composerPlainText = ""
|
||||
var composerFocused = false
|
||||
var composerActionsEnabled = false
|
||||
var composerExpanded = false
|
||||
var formatItems: [FormatItem] = .init()
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText,
|
||||
mode: state.composerMode))
|
||||
}
|
||||
state.bindings.composerActionsEnabled = false
|
||||
case .cancelReply:
|
||||
set(mode: .default)
|
||||
case .cancelEdit:
|
||||
|
||||
@@ -43,6 +43,10 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
messageComposer
|
||||
.environmentObject(context)
|
||||
.onTapGesture {
|
||||
guard !composerFocused else { return }
|
||||
composerFocused = true
|
||||
}
|
||||
if !context.composerActionsEnabled {
|
||||
sendButton
|
||||
}
|
||||
@@ -53,6 +57,7 @@ struct ComposerToolbar: View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
Button {
|
||||
context.composerActionsEnabled = false
|
||||
context.composerExpanded = false
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.compound.headingLG)
|
||||
@@ -89,7 +94,9 @@ struct ComposerToolbar: View {
|
||||
private var messageComposer: some View {
|
||||
MessageComposer(plainText: $context.composerPlainText,
|
||||
composerView: composerView,
|
||||
mode: context.viewState.composerMode) {
|
||||
mode: context.viewState.composerMode,
|
||||
showResizeGrabber: context.viewState.bindings.composerActionsEnabled,
|
||||
isExpanded: $context.composerExpanded) {
|
||||
context.send(viewAction: .sendMessage)
|
||||
} pasteAction: { provider in
|
||||
context.send(viewAction: .handlePasteOrDrop(provider: provider))
|
||||
|
||||
@@ -24,6 +24,8 @@ struct MessageComposer: View {
|
||||
@Binding var plainText: String
|
||||
let composerView: WysiwygComposerView
|
||||
let mode: RoomScreenComposerMode
|
||||
let showResizeGrabber: Bool
|
||||
@Binding var isExpanded: Bool
|
||||
let sendAction: EnterKeyHandler
|
||||
let pasteAction: PasteHandler
|
||||
let replyCancellationAction: () -> Void
|
||||
@@ -32,14 +34,43 @@ struct MessageComposer: View {
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@State private var isMultiline = false
|
||||
@State private var composerTranslation: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
let roundedRectangle = RoundedRectangle(cornerRadius: borderRadius)
|
||||
VStack(spacing: 0) {
|
||||
if showResizeGrabber {
|
||||
resizeGrabber
|
||||
}
|
||||
|
||||
mainContent
|
||||
.padding(.horizontal, 12.0)
|
||||
.clipShape(RoundedRectangle(cornerRadius: borderRadius))
|
||||
.background {
|
||||
let roundedRectangle = RoundedRectangle(cornerRadius: borderRadius)
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
roundedRectangle
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
|
||||
.opacity(focused ? 1 : 0)
|
||||
}
|
||||
}
|
||||
// Explicitly disable all animations to fix weirdness with the header immediately
|
||||
// appearing whilst the text field and keyboard are still animating up to it.
|
||||
.animation(.noAnimation, value: mode)
|
||||
}
|
||||
.gesture(showResizeGrabber ? dragGesture : nil)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(alignment: .leading, spacing: -6) {
|
||||
header
|
||||
HStack(alignment: .bottom) {
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
composerView
|
||||
.frame(minHeight: composerHeight, alignment: .top)
|
||||
.tint(.compound.iconAccentTertiary)
|
||||
.padding(.vertical, 10)
|
||||
.focused($focused)
|
||||
@@ -59,20 +90,11 @@ struct MessageComposer: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12.0)
|
||||
.clipped()
|
||||
.background {
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
roundedRectangle
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
|
||||
.opacity(focused ? 1 : 0)
|
||||
}
|
||||
}
|
||||
// Explicitly disable all animations to fix weirdness with the header immediately
|
||||
// appearing whilst the text field and keyboard are still animating up to it.
|
||||
.animation(.noAnimation, value: mode)
|
||||
}
|
||||
|
||||
private var composerHeight: CGFloat {
|
||||
let baseHeight = isExpanded ? ComposerConstant.maxHeight : ComposerConstant.minHeight
|
||||
return (baseHeight - composerTranslation).clamped(to: ComposerConstant.allowedHeightRange)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -95,6 +117,31 @@ struct MessageComposer: View {
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
private var resizeGrabber: some View {
|
||||
Capsule()
|
||||
.foregroundColor(Asset.Colors.grabber.swiftUIColor)
|
||||
.frame(width: 36, height: 5)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
composerTranslation += value.translation.height
|
||||
}
|
||||
.onEnded { _ in
|
||||
withElementAnimation(.easeIn(duration: 0.3)) {
|
||||
if composerTranslation > ComposerConstant.translationThreshold {
|
||||
isExpanded = false
|
||||
} else if composerTranslation < -ComposerConstant.translationThreshold {
|
||||
isExpanded = true
|
||||
}
|
||||
composerTranslation = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageComposerReplyHeader: View {
|
||||
@@ -169,6 +216,8 @@ struct MessageComposer_Previews: PreviewProvider {
|
||||
return MessageComposer(plainText: .constant(content),
|
||||
composerView: composerView,
|
||||
mode: mode,
|
||||
showResizeGrabber: false,
|
||||
isExpanded: .constant(false),
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
|
||||
@@ -61,7 +61,9 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
wysiwygViewModel = WysiwygComposerViewModel(minHeight: 22, maxExpandedHeight: 250)
|
||||
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
|
||||
maxCompressedHeight: ComposerConstant.maxHeight,
|
||||
maxExpandedHeight: ComposerConstant.maxHeight)
|
||||
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
|
||||
}
|
||||
|
||||
@@ -124,3 +126,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar))
|
||||
}
|
||||
}
|
||||
|
||||
enum ComposerConstant {
|
||||
static let minHeight: CGFloat = 22
|
||||
static let maxHeight: CGFloat = 250
|
||||
static let allowedHeightRange = minHeight...maxHeight
|
||||
static let translationThreshold: CGFloat = 60
|
||||
}
|
||||
|
||||
@@ -19,11 +19,18 @@ import WysiwygComposer
|
||||
|
||||
struct RoomScreen: View {
|
||||
@ObservedObject var context: RoomScreenViewModel.Context
|
||||
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModel.Context
|
||||
@State private var dragOver = false
|
||||
let composerToolbar: ComposerToolbar
|
||||
|
||||
private let attachmentButtonPadding = 10.0
|
||||
|
||||
init(context: RoomScreenViewModel.Context, composerToolbar: ComposerToolbar) {
|
||||
self.context = context
|
||||
self.composerToolbar = composerToolbar
|
||||
composerToolbarContext = composerToolbar.context
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
timeline
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
@@ -31,12 +38,20 @@ struct RoomScreen: View {
|
||||
composerToolbar
|
||||
.padding(.leading, attachmentButtonPadding)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom)
|
||||
.background {
|
||||
if composerToolbarContext.composerActionsEnabled {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.environmentObject(context)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(isNavigationBarHidden)
|
||||
.toolbar { toolbar }
|
||||
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
|
||||
.overlay { loadingIndicator }
|
||||
@@ -142,6 +157,10 @@ struct RoomScreen: View {
|
||||
RoomHeaderView(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private var isNavigationBarHidden: Bool {
|
||||
composerToolbarContext.composerActionsEnabled && composerToolbarContext.composerExpanded && UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -372,7 +372,9 @@ class MockScreen: Identifiable {
|
||||
var sessionVerificationControllerProxy = SessionVerificationControllerProxyMock.configureMock(requestDelay: .seconds(5))
|
||||
let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationControllerProxy)
|
||||
return SessionVerificationScreenCoordinator(parameters: parameters)
|
||||
case .userSessionScreen, .userSessionScreenReply:
|
||||
case .userSessionScreen, .userSessionScreenReply, .userSessionScreenRTE:
|
||||
let appSettings: AppSettings = ServiceLocator.shared.settings
|
||||
appSettings.richTextEditorEnabled = id == .userSessionScreenRTE
|
||||
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator())
|
||||
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)))
|
||||
@@ -383,7 +385,7 @@ class MockScreen: Identifiable {
|
||||
navigationSplitCoordinator: navigationSplitCoordinator,
|
||||
bugReportService: BugReportServiceMock(),
|
||||
roomTimelineControllerFactory: MockRoomTimelineControllerFactory(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
appSettings: appSettings,
|
||||
analytics: ServiceLocator.shared.analytics)
|
||||
|
||||
coordinator.start()
|
||||
|
||||
@@ -51,6 +51,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case sessionVerification
|
||||
case userSessionScreen
|
||||
case userSessionScreenReply
|
||||
case userSessionScreenRTE
|
||||
case roomDetailsScreen
|
||||
case roomDetailsScreenWithRoomAvatar
|
||||
case roomDetailsScreenWithEmptyTopic
|
||||
|
||||
Reference in New Issue
Block a user