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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 215 additions and 24 deletions

View File

@ -103,6 +103,7 @@
237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; };
245F7FE5961BD10C145A26E0 /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA689E792E679F5E3956F21 /* UITimelineView.swift */; };
24A75F72EEB7561B82D726FD /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; };
24B7CD41342C143117ADA768 /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B1CC9AA154F4D5435BF60A /* Comparable.swift */; };
24BDDD09A90B8BFE3793F3AA /* ClientProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */; };
25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */; };
256D76972BA3254F7CB7F88B /* LocationAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD8234D0E9C9B12BF9F240B /* LocationAnnotation.swift */; };
@ -1491,6 +1492,7 @@
E1E0B4A34E69BD2132BEC521 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarSize.swift; sourceTree = "<group>"; };
E26747B3154A5DBC3A7E24A5 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = "<group>"; };
E2B1CC9AA154F4D5435BF60A /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = "<group>"; };
E2DCA495ED42D2463DDAA94D /* TimelineBubbleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBubbleLayout.swift; sourceTree = "<group>"; };
E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModel.swift; sourceTree = "<group>"; };
E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainController.swift; sourceTree = "<group>"; };
@ -2205,6 +2207,7 @@
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */,
B6E89E530A8E92EC44301CA1 /* Bundle.swift */,
A9FAFE1C2149E6AC8156ED2B /* Collection.swift */,
E2B1CC9AA154F4D5435BF60A /* Comparable.swift */,
B383DCD3DCB19E00FD478A5F /* ConfirmationDialog.swift */,
2141693488CE5446BB391964 /* Date.swift */,
BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */,
@ -4460,6 +4463,7 @@
9FAF6DA7E8E85C9699757764 /* CollapsibleRoomTimelineView.swift in Sources */,
0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */,
663E198678778F7426A9B27D /* Collection.swift in Sources */,
24B7CD41342C143117ADA768 /* Comparable.swift in Sources */,
0B57C2399B9E1CE5CE0D8005 /* ComposerToolbar.swift in Sources */,
56BAB81A0D03C2EF09B86294 /* ComposerToolbarModels.swift in Sources */,
5995C63B1C61DE1373AA2BCE /* ComposerToolbarViewModel.swift in Sources */,

View File

@ -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
}
}

View File

@ -10,6 +10,7 @@
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

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")

View File

@ -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)
}
}

View File

@ -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>?

View File

@ -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:

View File

@ -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))

View File

@ -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: { },

View File

@ -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
}

View File

@ -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

View File

@ -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()

View File

@ -51,6 +51,7 @@ enum UITestsScreenIdentifier: String {
case sessionVerification
case userSessionScreen
case userSessionScreenReply
case userSessionScreenRTE
case roomDetailsScreen
case roomDetailsScreenWithRoomAvatar
case roomDetailsScreenWithEmptyTopic

View File

@ -45,4 +45,19 @@ class UserSessionScreenTests: XCTestCase {
try await app.assertScreenshot(.userSessionScreenReply)
}
func testUserSessionRTE() async throws {
let roomName = "First room"
let app = Application.launch(.userSessionScreenRTE)
app.buttons[A11yIdentifiers.homeScreen.roomName(roomName)].tap()
XCTAssert(app.staticTexts[roomName].waitForExistence(timeout: 5.0))
try await Task.sleep(for: .seconds(1))
app.buttons[A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions].tap()
try await app.assertScreenshot(.userSessionScreenRTE, step: 1)
app.buttons[A11yIdentifiers.roomScreen.attachmentPickerTextFormatting].tap()
try await app.assertScreenshot(.userSessionScreenRTE, step: 2)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -78,12 +78,12 @@ class ComposerToolbarViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.state.bindings.composerFocused)
}
func testRTEDisabledAfterSendingMessage() {
func testRTEEnabledAfterSendingMessage() {
viewModel.process(viewAction: .enableTextFormatting)
XCTAssertTrue(viewModel.state.bindings.composerFocused)
viewModel.state.composerEmpty = false
viewModel.process(viewAction: .sendMessage)
XCTAssertFalse(viewModel.state.bindings.composerActionsEnabled)
XCTAssertTrue(viewModel.state.bindings.composerActionsEnabled)
}
func testAlertIsShownAfterLinkAction() {