mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Add support for quick room search through the Cmd+K shortcut (#2363)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user