diff --git a/ElementX/Sources/Other/SwiftUI/Search.swift b/ElementX/Sources/Other/SwiftUI/Search.swift index 72d19e877..72ac42e1f 100644 --- a/ElementX/Sources/Other/SwiftUI/Search.swift +++ b/ElementX/Sources/Other/SwiftUI/Search.swift @@ -18,53 +18,153 @@ import SwiftUI import SwiftUIIntrospect extension View { - /// Disable the interactive dismiss while the search is on. - /// - Note: the modifier needs to be called before the `searchable` modifier to work properly - func disableInteractiveDismissOnSearch() -> some View { - modifier(InteractiveDismissSearchModifier()) - } - - /// Dismiss search when the view is disappearing. It helps to restore correct state on pop into a NavigationStack - /// - Note: the modifier needs to be called before the `searchable` modifier to work properly - func dismissSearchOnDisappear() -> some View { - modifier(DismissSearchOnDisappear()) - } - - /// Configures a searchable's underlying search controller. + /// A custom replacement for searchable that allows more precise configuration of the underlying search controller. + /// + /// Whilst we originally used introspect to configure parameters such as preventing the navigation bar from hiding + /// during a search, this proved unreliable from iOS 17.1 onwards. This implementation avoids all of those shenanigans. + /// **Note:** For some reason the font size is incorrect in the PreviewTests, buts its fine in UI tests and within the app. + /// /// - Parameters: + /// - query: The current or starting search text. + /// - placeholder: The string to display when there’s no other text in the text field. /// - hidesNavigationBar: A Boolean indicating whether to hide the navigation bar when searching. /// - showsCancelButton: A Boolean indicating whether the search controller manages the visibility of the search bar’s cancel button. - /// - /// This modifier may be moved into Compound once styles for the various configuration options have been defined. - func searchableConfiguration(hidesNavigationBar: Bool = true, - showsCancelButton: Bool = true) -> some View { - introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in - guard let searchController = navigationController.navigationBar.topItem?.searchController else { return } - searchController.hidesNavigationBarDuringPresentation = hidesNavigationBar - searchController.automaticallyShowsCancelButton = showsCancelButton - } + /// - disablesInteractiveDismiss: Whether or not interactive dismiss is disabled whilst the user is searching. + func searchController(query: Binding, + placeholder: String? = nil, + hidesNavigationBar: Bool = false, + showsCancelButton: Bool = true, + disablesInteractiveDismiss: Bool = false) -> some View { + modifier(SearchControllerModifier(searchQuery: query, + placeholder: placeholder, + hidesNavigationBar: hidesNavigationBar, + showsCancelButton: showsCancelButton, + disablesInteractiveDismiss: disablesInteractiveDismiss)) } } -private struct InteractiveDismissSearchModifier: ViewModifier { - @Environment(\.isSearching) private var isSearching - - func body(content: Content) -> some View { - content - .interactiveDismissDisabled(isSearching) - } -} - -private struct DismissSearchOnDisappear: ViewModifier { - @Environment(\.isSearching) private var isSearching - @Environment(\.dismissSearch) private var dismissSearch +private struct SearchControllerModifier: ViewModifier { + @Binding var searchQuery: String + + let placeholder: String? + let hidesNavigationBar: Bool + let showsCancelButton: Bool + let disablesInteractiveDismiss: Bool + + /// Whether or not the user is currently searching. When ``automaticallyShowsCancelButton`` + /// is `false`, checking if this value is `false` is pretty much meaningless. + @State private var isSearching = false func body(content: Content) -> some View { content + .interactiveDismissDisabled(!searchQuery.isEmpty && disablesInteractiveDismiss) + .background { + SearchController(searchQuery: $searchQuery, + placeholder: placeholder, + hidesNavigationBar: hidesNavigationBar, + showsCancelButton: showsCancelButton, + hidesSearchBarWhenScrolling: false, + isSearching: $isSearching) + } .onDisappear { + // Dismiss search when the view disappears to tidy up appearance when popping back to the view. if isSearching { - dismissSearch() + isSearching = false } } } } + +private struct SearchController: UIViewControllerRepresentable { + @Binding var searchQuery: String + + let placeholder: String? + let hidesNavigationBar: Bool + let showsCancelButton: Bool + let hidesSearchBarWhenScrolling: Bool + + @Binding var isSearching: Bool + + func makeUIViewController(context: Context) -> SearchInjectionViewController { + SearchInjectionViewController(searchController: context.coordinator.searchController, + hidesSearchBarWhenScrolling: hidesSearchBarWhenScrolling) + } + + func updateUIViewController(_ viewController: SearchInjectionViewController, context: Context) { + let searchController = viewController.searchController + searchController.searchBar.text = searchQuery + searchController.hidesNavigationBarDuringPresentation = hidesNavigationBar + searchController.automaticallyShowsCancelButton = showsCancelButton + + if searchController.isActive, !isSearching { + DispatchQueue.main.async { searchController.isActive = false } + } else if !searchController.isActive, isSearching { + DispatchQueue.main.async { searchController.isActive = true } + } + + if let placeholder { // Blindly setting nil clears the default placeholder. + searchController.searchBar.placeholder = placeholder + } + + viewController.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling + } + + func makeCoordinator() -> Coordinator { + Coordinator(searchQuery: $searchQuery, isSearching: $isSearching) + } + + class Coordinator: NSObject, UISearchBarDelegate, UISearchControllerDelegate { + let searchController = UISearchController() + private let searchQuery: Binding + private let isSearching: Binding + + init(searchQuery: Binding, isSearching: Binding) { + self.searchQuery = searchQuery + self.isSearching = isSearching + + super.init() + + searchController.delegate = self + searchController.searchBar.delegate = self + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + searchQuery.wrappedValue = searchText + } + + func didPresentSearchController(_ searchController: UISearchController) { + isSearching.wrappedValue = true + } + + func willDismissSearchController(_ searchController: UISearchController) { + // Clear any search results when the user taps cancel. + searchQuery.wrappedValue = "" + } + + func didDismissSearchController(_ searchController: UISearchController) { + isSearching.wrappedValue = false + } + } + + class SearchInjectionViewController: UIViewController { + let searchController: UISearchController + var hidesSearchBarWhenScrolling: Bool + + init(searchController: UISearchController, hidesSearchBarWhenScrolling: Bool) { + self.searchController = searchController + self.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling + + super.init(nibName: nil, bundle: nil) + + view.alpha = 0 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func willMove(toParent parent: UIViewController?) { + parent?.navigationItem.searchController = searchController + parent?.navigationItem.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling + } + } +} diff --git a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift index dae4a7f39..dcf7be4a5 100644 --- a/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift +++ b/ElementX/Sources/Screens/InviteUsersScreen/View/InviteUsersScreen.swift @@ -31,10 +31,10 @@ struct InviteUsersScreen: View { .navigationTitle(L10n.screenCreateRoomAddPeopleTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } - .disableInteractiveDismissOnSearch() - .dismissSearchOnDisappear() - .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone) - .searchableConfiguration(hidesNavigationBar: false) + .searchController(query: $context.searchQuery, + placeholder: L10n.commonSearchForSomeone, + showsCancelButton: false, + disablesInteractiveDismiss: true) .compoundSearchField() .alert(item: $context.alertInfo) } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift index 64ed2168e..e71495be2 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift @@ -57,8 +57,7 @@ struct MessageForwardingScreen: View { .disabled(context.viewState.selectedRoomID == nil) } } - .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always)) - .searchableConfiguration(hidesNavigationBar: false) + .searchController(query: $context.searchQuery, showsCancelButton: false) .compoundSearchField() .disableAutocorrection(true) } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift index d99a399ff..50148c723 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift @@ -260,9 +260,9 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview { static var previews: some View { NotificationSettingsScreen(context: viewModel.context) - .snapshot(delay: 0.1) + .snapshot(delay: 2.0) NotificationSettingsScreen(context: viewModelConfigurationMismatch.context) - .snapshot(delay: 0.1) + .snapshot(delay: 2.0) .previewDisplayName("Configuration mismatch") } } diff --git a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift index 8e2e85d94..f0c9b868c 100644 --- a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift @@ -34,10 +34,10 @@ struct StartChatScreen: View { .navigationTitle(L10n.actionStartChat) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } - .disableInteractiveDismissOnSearch() - .dismissSearchOnDisappear() - .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone) - .searchableConfiguration(hidesNavigationBar: false) + .searchController(query: $context.searchQuery, + placeholder: L10n.commonSearchForSomeone, + showsCancelButton: false, + disablesInteractiveDismiss: true) .compoundSearchField() .alert(item: $context.alertInfo) } diff --git a/UnitTests/__Snapshots__/PreviewTests/test_inviteUsersScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_inviteUsersScreen.1.png index 4fb56b1b0..cb1441f2c 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_inviteUsersScreen.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_inviteUsersScreen.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:630f7168d3ed6a7e1a9d8084af15ab794ba79ece4c63e7147e127a36fb853ce2 -size 78972 +oid sha256:da7a4e7370b3955ed4ff24dfda68a2e911dc5c22c6a05891cb40850935b5fac0 +size 79353 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_messageForwardingScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_messageForwardingScreen.1.png index 17dd192e2..2b5082f45 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_messageForwardingScreen.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_messageForwardingScreen.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9eafc4604f3d3dabdbf85d97ca643be13cb83111ad98def8cc383108067a0269 -size 181066 +oid sha256:56af9ef188080e0fbbc542599c150c2e923be8fa1895b5262662e86b6ff8e8ad +size 181373 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_startChatScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_startChatScreen.1.png index 39aecff6f..5f06ff08a 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_startChatScreen.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_startChatScreen.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:33495c3c814ac0162bb8759acc1c6225271aa62dcbbd43f3076e77ffdae1d879 -size 96134 +oid sha256:c7e0c6e4e771ecfe02d50767c3c09740219de4a2aa75d298073e0db1a58e3df7 +size 96770