UISearchBar resigns first responder when typing in UICollectionView

The following is a UIKit app with a collection view with one section, whose supplementary view sports a search bar.

When you type, it often resigns first responder.

import UIKit

class ViewController: UIViewController {
    let words = ["foo", "bar"]
    var filteredWords = ["foo", "bar"] {
        didSet {
            dataSource.apply(self.snapshot)
        }
    }
    
    var collectionView: UICollectionView!
        
    var dataSource: UICollectionViewDiffableDataSource<String, String>!
    
    var snapshot: NSDiffableDataSourceSnapshot<String, String> {
        var snapshot = NSDiffableDataSourceSnapshot<String, String>()
        snapshot.appendSections(["main"])
        snapshot.appendItems(filteredWords)
        return snapshot
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.rightBarButtonItem = .init(title: "Apply", style: .plain, target: self, action: #selector(apply))
        
        configureHierarchy()
        configureDataSource()
    }
    
    @objc func apply() {
        dataSource.apply(self.snapshot)
    }
    
    func configureHierarchy() {
        collectionView = .init(frame: .zero, collectionViewLayout: createLayout())
        view.addSubview(collectionView)
        collectionView.frame = view.bounds
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    }
    
    func createLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { section, layoutEnvironment in
            var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
            config.headerMode = .supplementary
            return NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
        }
    }
    
    func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { _, _, _ in }
        
        dataSource = .init(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
        }
        
        let searchbarHeaderRegistration = UICollectionView.SupplementaryRegistration<SearchBarCell>(elementKind: UICollectionView.elementKindSectionHeader) { cell, elementKind, indexPath in
            cell.searchBar.delegate = self
        }
                
        dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
            collectionView.dequeueConfiguredReusableSupplementary(using: searchbarHeaderRegistration, for: indexPath)
        }
        
        dataSource.apply(self.snapshot, animatingDifferences: false)
    }
}

extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            
            filteredWords = words.filter { $0.hasPrefix(searchText) }
        }
    }
    
    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            
            filteredWords = words
        }
    }
}

class SearchBarCell: UICollectionViewListCell {
    let searchBar = UISearchBar()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(searchBar)
        searchBar.pinToSuperview()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension UIView {
    func pin(
        to object: CanBePinnedTo,
        top: CGFloat = 0,
        bottom: CGFloat = 0,
        leading: CGFloat = 0,
        trailing: CGFloat = 0
    ) {
        self.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            self.topAnchor.constraint(equalTo: object.topAnchor, constant: top),
            self.bottomAnchor.constraint(equalTo: object.bottomAnchor, constant: bottom),
            self.leadingAnchor.constraint(equalTo: object.leadingAnchor, constant: leading),
            self.trailingAnchor.constraint(equalTo: object.trailingAnchor, constant: trailing),
        ])
    }
    
    func pinToSuperview(
        top: CGFloat = 0,
        bottom: CGFloat = 0,
        leading: CGFloat = 0,
        trailing: CGFloat = 0,
        file: StaticString = #file,
        line: UInt = #line
    ) {
        guard let superview = self.superview else {
            print(">> \(#function) failed in file: \(String.localFilePath(from: file)), at line: \(line): could not find \(Self.self).superView.")
            return
        }
        
        self.pin(to: superview, top: top, bottom: bottom, leading: leading, trailing: trailing)
    }
    
    func pinToSuperview(constant c: CGFloat = 0, file: StaticString = #file, line: UInt = #line) {
        self.pinToSuperview(top: c, bottom: -c, leading: c, trailing: -c, file: file, line: line)
    }
}

@MainActor
protocol CanBePinnedTo {
    var topAnchor: NSLayoutYAxisAnchor { get }
    var bottomAnchor: NSLayoutYAxisAnchor { get }
    var leadingAnchor: NSLayoutXAxisAnchor { get }
    var trailingAnchor: NSLayoutXAxisAnchor { get }
}

extension UIView: CanBePinnedTo { }
extension UILayoutGuide: CanBePinnedTo { }

extension String {
    static func localFilePath(from fullFilePath: StaticString = #file) -> Self {
        URL(fileURLWithPath: "\(fullFilePath)").lastPathComponent
    }
}

By the way if you remove the dispatch blocks, the app simply crashes complaining that you're trying to apply datasource snapshots from both the main queue and other queues.

Xcode 15.3 iOS 17.4 simulator, MacBook Air M1 8GB macOS Sonoma 14.4.1

Replies

If you use cell registrations instead of supplementary registrations, the keyboard doesn't dismiss for any reason and you can also get rid of the dispatch blocks:

import UIKit

class ViewController: UIViewController {
    let words = ["foo", "bar"]
    var filteredWords = ["foo", "bar"] {
        didSet {
            dataSource.apply(self.snapshot)
        }
    }
    
    var collectionView: UICollectionView!
        
    var dataSource: UICollectionViewDiffableDataSource<String, String>!
    
    var snapshot: NSDiffableDataSourceSnapshot<String, String> {
        var snapshot = NSDiffableDataSourceSnapshot<String, String>()
        snapshot.appendSections(["main"])
        snapshot.appendItems(["search bar id"])
        snapshot.appendItems(filteredWords)
        return snapshot
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.rightBarButtonItem = .init(title: "Apply", style: .plain, target: self, action: #selector(apply))
        
        configureHierarchy()
        configureDataSource()
    }
    
    @objc func apply() {
        dataSource.apply(self.snapshot)
    }
    
    func configureHierarchy() {
        collectionView = .init(frame: .zero, collectionViewLayout: createLayout())
        view.addSubview(collectionView)
        collectionView.frame = view.bounds
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    }
    
    func createLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { section, layoutEnvironment in
            var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
            config.headerMode = .firstItemInSection
            return NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
        }
    }
    
    func configureDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, String> { _, _, _ in }
        
        let searchBarCellRegistration = UICollectionView.CellRegistration<SearchBarCell, String>{ cell, indexPath, itemIdentifier in
            cell.searchBar.delegate = self
        }

        dataSource = .init(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
            if indexPath.row == 0 {
                collectionView.dequeueConfiguredReusableCell(using: searchBarCellRegistration, for: indexPath, item: itemIdentifier)
            } else {
                collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
            }
        }
        
        dataSource.apply(self.snapshot, animatingDifferences: false)
    }
}

extension ViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        filteredWords = words.filter { $0.hasPrefix(searchText) }
    }
    
    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        filteredWords = words
    }
}

class SearchBarCell: UICollectionViewListCell {
    let searchBar = UISearchBar()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        contentView.addSubview(searchBar)
        searchBar.pinToSuperview()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension UIView {
    func pin(
        to object: CanBePinnedTo,
        top: CGFloat = 0,
        bottom: CGFloat = 0,
        leading: CGFloat = 0,
        trailing: CGFloat = 0
    ) {
        self.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            self.topAnchor.constraint(equalTo: object.topAnchor, constant: top),
            self.bottomAnchor.constraint(equalTo: object.bottomAnchor, constant: bottom),
            self.leadingAnchor.constraint(equalTo: object.leadingAnchor, constant: leading),
            self.trailingAnchor.constraint(equalTo: object.trailingAnchor, constant: trailing),
        ])
    }
    
    func pinToSuperview(
        top: CGFloat = 0,
        bottom: CGFloat = 0,
        leading: CGFloat = 0,
        trailing: CGFloat = 0,
        file: StaticString = #file,
        line: UInt = #line
    ) {
        guard let superview = self.superview else {
            print(">> \(#function) failed in file: \(String.localFilePath(from: file)), at line: \(line): could not find \(Self.self).superView.")
            return
        }
        
        self.pin(to: superview, top: top, bottom: bottom, leading: leading, trailing: trailing)
    }
    
    func pinToSuperview(constant c: CGFloat = 0, file: StaticString = #file, line: UInt = #line) {
        self.pinToSuperview(top: c, bottom: -c, leading: c, trailing: -c, file: file, line: line)
    }
}

@MainActor
protocol CanBePinnedTo {
    var topAnchor: NSLayoutYAxisAnchor { get }
    var bottomAnchor: NSLayoutYAxisAnchor { get }
    var leadingAnchor: NSLayoutXAxisAnchor { get }
    var trailingAnchor: NSLayoutXAxisAnchor { get }
}

extension UIView: CanBePinnedTo { }
extension UILayoutGuide: CanBePinnedTo { }

extension String {
    static func localFilePath(from fullFilePath: StaticString = #file) -> Self {
        URL(fileURLWithPath: "\(fullFilePath)").lastPathComponent
    }
}

Basically don't use supplementary views unless absolutely necessary: it also seems that there's no declarative way to update them without losing animations (Apple's guided project "Modern Collection Views" doesn't seem to provide a solution, nor do these posts: https://stackoverflow.com/questions/78311570/how-do-i-update-a-collection-view-supplementary-view-without-giving-up-on-animat, https://forums.developer.apple.com/forums/thread/749847).