Add support for quick room search through the Cmd+K shortcut (#2363)

This commit is contained in:
Stefan Ceriu
2024-01-29 14:55:11 +02:00
committed by GitHub
parent c442afa5f6
commit 197b088662
28 changed files with 796 additions and 61 deletions
@@ -0,0 +1,221 @@
//
// Copyright 2022 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.
//
import Compound
import SwiftUI
struct GlobalSearchScreen: View {
@ObservedObject var context: GlobalSearchScreenViewModel.Context
@State private var selectedRoom: GlobalSearchRoom?
@FocusState private var searchFieldFocus
var body: some View {
List {
header
Section {
ForEach(context.viewState.rooms) { room in
GlobalSearchScreenListRow(room: room, context: context)
.listRowBackground(backgroundColor(for: room))
.listRowInsets(.init())
.onTapGesture {
context.send(viewAction: .select(roomID: room.id))
}
.onAppear {
if room == context.viewState.rooms.first {
context.send(viewAction: .reachedTop)
} else if room == context.viewState.rooms.last {
context.send(viewAction: .reachedBottom)
}
}
}
}
}
.listStyle(.plain)
.frame(maxWidth: 700, maxHeight: 800)
.background(.compound.bgCanvasDefault)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.5).ignoresSafeArea())
.background { keyboardShortcuts }
.onAppear {
selectedRoom = context.viewState.rooms.first
searchFieldFocus = true
}
.onChange(of: context.viewState.rooms) { _ in
selectedRoom = context.viewState.rooms.first
}
.onTapGesture {
context.send(viewAction: .dismiss)
}
}
private var header: some View {
GlobalSearchTextFieldRepresentable(placeholder: L10n.actionSearch, text: $context.searchQuery) { keyCode in
switch keyCode {
case .keyboardUpArrow:
moveToNextEntry(backwards: true)
return true
case .keyboardDownArrow:
moveToNextEntry()
return true
case .keyboardReturnOrEnter, .keyboardReturn:
if let selectedRoom {
context.send(viewAction: .select(roomID: selectedRoom.id))
}
return true
case .keyboardEscape:
context.send(viewAction: .dismiss)
return true
default:
return false
}
} endEditingHandler: {
if let selectedRoom {
context.send(viewAction: .select(roomID: selectedRoom.id))
} else { // Bring the focus back to the text field
searchFieldFocus = true
}
}
.focused($searchFieldFocus)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.textInputAutocapitalization(.never)
}
private var keyboardShortcuts: some View {
Group {
Button("") {
context.send(viewAction: .dismiss)
}
// Need this to enable escape on the textfield and forward the presses
.keyboardShortcut(.escape, modifiers: [])
}
}
private func backgroundColor(for room: GlobalSearchRoom) -> Color {
if selectedRoom == room {
.compound.bgSubtlePrimary
} else {
.compound.bgCanvasDefault
}
}
private func moveToNextEntry(backwards: Bool = false) {
guard let selectedRoom else {
selectedRoom = context.viewState.rooms.first
return
}
guard let currentIndex = context.viewState.rooms.firstIndex(of: selectedRoom) else {
return
}
let nextIndex = (backwards ? currentIndex - 1 : currentIndex + 1)
guard context.viewState.rooms.indices.contains(nextIndex) else {
return
}
self.selectedRoom = context.viewState.rooms[nextIndex]
}
}
private struct GlobalSearchTextFieldRepresentable: UIViewRepresentable {
let placeholder: String
@Binding var text: String
let keyPressHandler: (UIKeyboardHIDUsage) -> Bool
let endEditingHandler: () -> Void
func makeUIView(context: Context) -> UITextField {
let textField = GlobalSearchTextField(keyPressHandler: keyPressHandler)
textField.delegate = context.coordinator
textField.autocorrectionType = .no
textField.placeholder = placeholder
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = text
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, endEditingHandler: endEditingHandler)
}
class Coordinator: NSObject, UITextFieldDelegate {
var text: Binding<String>
let endEditingHandler: () -> Void
init(text: Binding<String>, endEditingHandler: @escaping () -> Void) {
self.text = text
self.endEditingHandler = endEditingHandler
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
// pressesBegan sometimes doesn't receive return events. Handle it here instead
if string.rangeOfCharacter(from: .newlines) != nil {
endEditingHandler()
return false
}
let currentText = textField.text ?? ""
DispatchQueue.main.async {
self.text.wrappedValue = (currentText as NSString).replacingCharacters(in: range, with: string)
}
return true
}
}
}
private class GlobalSearchTextField: UITextField {
let keyPressHandler: (UIKeyboardHIDUsage) -> Bool
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError()
}
init(keyPressHandler: @escaping (UIKeyboardHIDUsage) -> Bool) {
self.keyPressHandler = keyPressHandler
super.init(frame: .zero)
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else { return }
if keyPressHandler(key.keyCode) {
return
}
super.pressesBegan(presses, with: event)
}
}
// MARK: - Previews
struct GlobalSearchScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)),
imageProvider: MockMediaProvider())
static var previews: some View {
NavigationStack {
GlobalSearchScreen(context: viewModel.context)
}
}
}
@@ -0,0 +1,62 @@
//
// Copyright 2024 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.
//
import Compound
import SwiftUI
struct GlobalSearchScreenListRow: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let room: GlobalSearchRoom
let context: GlobalSearchScreenViewModel.Context
var body: some View {
ZStack { // The list row swallows listRowBackgrounds for some reason
ListRow(label: .avatar(title: room.name,
description: room.alias ?? room.id,
icon: avatar),
kind: .label)
}
}
@ViewBuilder @MainActor
var avatar: some View {
if dynamicTypeSize < .accessibility3 {
LoadableAvatarImage(url: room.avatarURL,
name: room.name,
contentID: room.id,
avatarSize: .room(on: .messageForwarding),
imageProvider: context.imageProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
}
}
struct GlobalSearchScreenListRow_Previews: PreviewProvider, TestablePreview {
static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)),
imageProvider: MockMediaProvider())
static var previews: some View {
List {
GlobalSearchScreenListRow(room: .init(id: "123",
name: "Tech central",
alias: "The best place in the whole wide world",
avatarURL: .picturesDirectory),
context: viewModel.context)
}
}
}