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:
Alfonso Grillo
2023-09-11 09:54:37 +02:00
committed by GitHub
parent 89c4786af7
commit bd4ee92124
23 changed files with 215 additions and 24 deletions
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "199",
"green" : "197",
"red" : "197"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "89",
"green" : "83",
"red" : "81"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -10,6 +10,7 @@
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -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