diff --git a/SearchTextField/Classes/SearchTextField.swift b/SearchTextField/Classes/SearchTextField.swift index 9cf8f55..6662e53 100755 --- a/SearchTextField/Classes/SearchTextField.swift +++ b/SearchTextField/Classes/SearchTextField.swift @@ -9,47 +9,49 @@ import UIKit open class SearchTextField: UITextField { - + //////////////////////////////////////////////////////////////////////// // Public interface - + /// Maximum number of results to be shown in the suggestions list open var maxNumberOfResults = 0 - + /// Maximum height of the results list open var maxResultsListHeight = 0 - + /// Indicate if this field has been interacted with yet open var interactedWith = false - + /// Indicate if keyboard is showing or not open var keyboardIsShowing = false /// How long to wait before deciding typing has stopped open var typingStoppedDelay = 0.8 - + + open var autoFillOnExit = true + /// Set your custom visual theme, or just choose between pre-defined SearchTextFieldTheme.lightTheme() and SearchTextFieldTheme.darkTheme() themes open var theme = SearchTextFieldTheme.lightTheme() { didSet { tableView?.reloadData() - + if let placeholderColor = theme.placeholderColor { if let placeholderString = placeholder { self.attributedPlaceholder = NSAttributedString(string: placeholderString, attributes: [NSAttributedString.Key.foregroundColor: placeholderColor]) } - + self.placeholderLabel?.textColor = placeholderColor } - + if let hightlightedFont = self.highlightAttributes[.font] as? UIFont { self.highlightAttributes[.font] = hightlightedFont.withSize(self.theme.font.pointSize) } } } - + /// Show the suggestions list without filter when the text field is focused open var startVisible = false - + /// Show the suggestions list without filter even if the text field is not focused open var startVisibleWithoutInteraction = false { didSet { @@ -58,47 +60,47 @@ open class SearchTextField: UITextField { } } } - + /// Set an array of SearchTextFieldItem's to be used for suggestions open func filterItems(_ items: [SearchTextFieldItem]) { filterDataSource = items } - + /// Set an array of strings to be used for suggestions open func filterStrings(_ strings: [String]) { var items = [SearchTextFieldItem]() - + for value in strings { items.append(SearchTextFieldItem(title: value)) } - + filterItems(items) } - + /// Closure to handle when the user pick an item open var itemSelectionHandler: SearchTextFieldItemHandler? - + /// Closure to handle when the user stops typing open var userStoppedTypingHandler: (() -> Void)? - + /// Set your custom set of attributes in order to highlight the string found in each item open var highlightAttributes: [NSAttributedString.Key: AnyObject] = [.font: UIFont.boldSystemFont(ofSize: 10)] - + /// Start showing the default loading indicator, useful for searches that take some time. open func showLoadingIndicator() { self.rightViewMode = .always indicator.startAnimating() } - + /// Force the results list to adapt to RTL languages open var forceRightToLeft = false - + /// Hide the default loading indicator open func stopLoadingIndicator() { self.rightViewMode = .never indicator.stopAnimating() } - + /// When InlineMode is true, the suggestions appear in the same line than the entered string. It's useful for email domains suggestion for example. open var inlineMode: Bool = false { didSet { @@ -108,22 +110,22 @@ open class SearchTextField: UITextField { } } } - + /// Only valid when InlineMode is true. The suggestions appear after typing the provided string (or even better a character like '@') open var startFilteringAfter: String? - + /// Min number of characters to start filtering open var minCharactersNumberToStartFiltering: Int = 0 /// Force no filtering (display the entire filtered data source) open var forceNoFiltering: Bool = false - + /// If startFilteringAfter is set, and startSuggestingImmediately is true, the list of suggestions appear immediately open var startSuggestingImmediately = false - + /// Allow to decide the comparision options open var comparisonOptions: NSString.CompareOptions = [.caseInsensitive] - + /// Set the results list's header open var resultsListHeader: UIView? @@ -132,10 +134,10 @@ open class SearchTextField: UITextField { open var tableYOffset: CGFloat = 0.0 open var tableCornerRadius: CGFloat = 2.0 open var tableBottomMargin: CGFloat = 10.0 - + //////////////////////////////////////////////////////////////////////// // Private implementation - + fileprivate var tableView: UITableView? fileprivate var shadowView: UIView? fileprivate var direction: Direction = .down @@ -146,63 +148,63 @@ open class SearchTextField: UITextField { fileprivate static let cellIdentifier = "APSearchTextFieldCell" fileprivate let indicator = UIActivityIndicatorView(style: .gray) fileprivate var maxTableViewSize: CGFloat = 0 - + fileprivate var filteredResults = [SearchTextFieldItem]() fileprivate var filterDataSource = [SearchTextFieldItem]() { didSet { filter(forceShowAll: forceNoFiltering) buildSearchTableView() - + if startVisibleWithoutInteraction { textFieldDidChange() } } } - + fileprivate var currentInlineItem = "" - + deinit { NotificationCenter.default.removeObserver(self) } - + open override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) tableView?.removeFromSuperview() } - + override open func willMove(toSuperview newSuperview: UIView?) { super.willMove(toSuperview: newSuperview) - + self.addTarget(self, action: #selector(SearchTextField.textFieldDidChange), for: .editingChanged) self.addTarget(self, action: #selector(SearchTextField.textFieldDidBeginEditing), for: .editingDidBegin) self.addTarget(self, action: #selector(SearchTextField.textFieldDidEndEditing), for: .editingDidEnd) self.addTarget(self, action: #selector(SearchTextField.textFieldDidEndEditingOnExit), for: .editingDidEndOnExit) - + NotificationCenter.default.addObserver(self, selector: #selector(SearchTextField.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(SearchTextField.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(SearchTextField.keyboardDidChangeFrame(_:)), name: UIResponder.keyboardDidChangeFrameNotification, object: nil) } - + override open func layoutSubviews() { super.layoutSubviews() - + if inlineMode { buildPlaceholderLabel() } else { buildSearchTableView() } - + // Create the loading indicator indicator.hidesWhenStopped = true self.rightView = indicator } - + override open func rightViewRect(forBounds bounds: CGRect) -> CGRect { var rightFrame = super.rightViewRect(forBounds: bounds) rightFrame.origin.x -= 5 return rightFrame } - + // Create the filter table and shadow view fileprivate func buildSearchTableView() { guard let tableView = tableView, let shadowView = shadowView else { @@ -211,7 +213,7 @@ open class SearchTextField: UITextField { buildSearchTableView() return } - + tableView.layer.masksToBounds = true tableView.layer.borderWidth = theme.borderWidth > 0 ? theme.borderWidth : 0.5 tableView.dataSource = self @@ -221,29 +223,29 @@ open class SearchTextField: UITextField { if forceRightToLeft { tableView.semanticContentAttribute = .forceRightToLeft } - + shadowView.backgroundColor = UIColor.lightText shadowView.layer.shadowColor = UIColor.black.cgColor shadowView.layer.shadowOffset = CGSize.zero shadowView.layer.shadowOpacity = 1 - + self.window?.addSubview(tableView) - + redrawSearchTableView() } - + fileprivate func buildPlaceholderLabel() { var newRect = self.placeholderRect(forBounds: self.bounds) var caretRect = self.caretRect(for: self.beginningOfDocument) let textRect = self.textRect(forBounds: self.bounds) - + if let range = textRange(from: beginningOfDocument, to: endOfDocument) { caretRect = self.firstRect(for: range) } - + newRect.origin.x = caretRect.origin.x + caretRect.size.width + textRect.origin.x newRect.size.width = newRect.size.width - newRect.origin.x - + if let placeholderLabel = placeholderLabel { placeholderLabel.font = self.font placeholderLabel.frame = newRect @@ -252,27 +254,27 @@ open class SearchTextField: UITextField { placeholderLabel?.font = self.font placeholderLabel?.backgroundColor = UIColor.clear placeholderLabel?.lineBreakMode = .byClipping - + if let placeholderColor = self.attributedPlaceholder?.attribute(NSAttributedString.Key.foregroundColor, at: 0, effectiveRange: nil) as? UIColor { placeholderLabel?.textColor = placeholderColor } else { placeholderLabel?.textColor = UIColor ( red: 0.8, green: 0.8, blue: 0.8, alpha: 1.0 ) } - + self.addSubview(placeholderLabel!) } } - + // Re-set frames and theme colors fileprivate func redrawSearchTableView() { if inlineMode { tableView?.isHidden = true return } - + if let tableView = tableView { guard let frame = self.superview?.convert(self.frame, to: nil) else { return } - + //TableViews use estimated cell heights to calculate content size until they // are on-screen. We must set this to the theme cell height to avoid getting an // incorrect contentSize when we have specified non-standard fonts and/or @@ -280,23 +282,23 @@ open class SearchTextField: UITextField { // are recognized if changed after the tableView is created tableView.estimatedRowHeight = theme.cellHeight if self.direction == .down { - + var tableHeight: CGFloat = 0 if keyboardIsShowing, let keyboardHeight = keyboardFrame?.size.height { tableHeight = min((tableView.contentSize.height), (UIScreen.main.bounds.size.height - frame.origin.y - frame.height - keyboardHeight)) } else { tableHeight = min((tableView.contentSize.height), (UIScreen.main.bounds.size.height - frame.origin.y - frame.height)) } - + if maxResultsListHeight > 0 { tableHeight = min(tableHeight, CGFloat(maxResultsListHeight)) } - + // Set a bottom margin of 10p if tableHeight < tableView.contentSize.height { tableHeight -= tableBottomMargin } - + var tableViewFrame = CGRect(x: 0, y: 0, width: frame.size.width - 4, height: tableHeight) tableViewFrame.origin = self.convert(tableViewFrame.origin, to: nil) tableViewFrame.origin.x += 2 + tableXOffset @@ -305,7 +307,7 @@ open class SearchTextField: UITextField { UIView.animate(withDuration: 0.2, animations: { [weak self] in self?.tableView?.frame = tableViewFrame }) - + var shadowFrame = CGRect(x: 0, y: 0, width: frame.size.width - 6, height: 1) shadowFrame.origin = self.convert(shadowFrame.origin, to: nil) shadowFrame.origin.x += 3 @@ -318,23 +320,23 @@ open class SearchTextField: UITextField { self?.shadowView?.frame = CGRect(x: frame.origin.x + 3, y: (frame.origin.y + 3), width: frame.size.width - 6, height: 1) }) } - + superview?.bringSubviewToFront(tableView) superview?.bringSubviewToFront(shadowView!) - + if self.isFirstResponder { superview?.bringSubviewToFront(self) } - + tableView.layer.borderColor = theme.borderColor.cgColor tableView.layer.cornerRadius = tableCornerRadius tableView.separatorColor = theme.separatorColor tableView.backgroundColor = theme.bgColor - + tableView.reloadData() } } - + // Handle keyboard events @objc open func keyboardWillShow(_ notification: Notification) { if !keyboardIsShowing && isEditing { @@ -344,7 +346,7 @@ open class SearchTextField: UITextField { prepareDrawTableResult() } } - + @objc open func keyboardWillHide(_ notification: Notification) { if keyboardIsShowing { keyboardIsShowing = false @@ -352,30 +354,30 @@ open class SearchTextField: UITextField { redrawSearchTableView() } } - + @objc open func keyboardDidChangeFrame(_ notification: Notification) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.keyboardFrame = ((notification as NSNotification).userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue self?.prepareDrawTableResult() } } - + @objc open func typingDidStop() { self.userStoppedTypingHandler?() } - + // Handle text field changes @objc open func textFieldDidChange() { if !inlineMode && tableView == nil { buildSearchTableView() } - + interactedWith = true - + // Detect pauses while typing timer?.invalidate() timer = Timer.scheduledTimer(timeInterval: typingStoppedDelay, target: self, selector: #selector(SearchTextField.typingDidStop), userInfo: self, repeats: false) - + if text!.isEmpty { clearResults() tableView?.reloadData() @@ -387,10 +389,10 @@ open class SearchTextField: UITextField { filter(forceShowAll: forceNoFiltering) prepareDrawTableResult() } - + buildPlaceholderLabel() } - + @objc open func textFieldDidBeginEditing() { if (startVisible || startVisibleWithoutInteraction) && text!.isEmpty { clearResults() @@ -398,14 +400,15 @@ open class SearchTextField: UITextField { } placeholderLabel?.attributedText = nil } - + @objc open func textFieldDidEndEditing() { clearResults() tableView?.reloadData() placeholderLabel?.attributedText = nil } - + @objc open func textFieldDidEndEditingOnExit() { + guard autoFillOnExit == true else { return } if let firstElement = filteredResults.first { if let itemSelectionHandler = self.itemSelectionHandler { itemSelectionHandler(filteredResults, 0) @@ -413,7 +416,7 @@ open class SearchTextField: UITextField { else { if inlineMode, let filterAfter = startFilteringAfter { let stringElements = self.text?.components(separatedBy: filterAfter) - + self.text = stringElements!.first! + filterAfter + firstElement.title } else { self.text = firstElement.title @@ -421,48 +424,48 @@ open class SearchTextField: UITextField { } } } - + open func hideResultsList() { if let tableFrame:CGRect = tableView?.frame { let newFrame = CGRect(x: tableFrame.origin.x, y: tableFrame.origin.y, width: tableFrame.size.width, height: 0.0) UIView.animate(withDuration: 0.2, animations: { [weak self] in self?.tableView?.frame = newFrame }) - + } } - + fileprivate func filter(forceShowAll addAll: Bool) { clearResults() - + if text!.count < minCharactersNumberToStartFiltering { return } - + for i in 0 ..< filterDataSource.count { - - let item = filterDataSource[i] - + + var item = filterDataSource[i] + if !inlineMode { // Find text in title and subtitle let titleFilterRange = (item.title as NSString).range(of: text!, options: comparisonOptions) let subtitleFilterRange = item.subtitle != nil ? (item.subtitle! as NSString).range(of: text!, options: comparisonOptions) : NSMakeRange(NSNotFound, 0) - + if titleFilterRange.location != NSNotFound || subtitleFilterRange.location != NSNotFound || addAll { item.attributedTitle = NSMutableAttributedString(string: item.title) item.attributedSubtitle = NSMutableAttributedString(string: (item.subtitle != nil ? item.subtitle! : "")) - + item.attributedTitle!.setAttributes(highlightAttributes, range: titleFilterRange) - + if subtitleFilterRange.location != NSNotFound { item.attributedSubtitle!.setAttributes(highlightAttributesForSubtitle(), range: subtitleFilterRange) } - + filteredResults.append(item) } } else { var textToFilter = text!.lowercased() - + if inlineMode, let filterAfter = startFilteringAfter { if let suffixToFilter = textToFilter.components(separatedBy: filterAfter).last, (suffixToFilter != "" || startSuggestingImmediately == true), textToFilter != suffixToFilter { textToFilter = suffixToFilter @@ -471,34 +474,34 @@ open class SearchTextField: UITextField { return } } - + if item.title.lowercased().hasPrefix(textToFilter) { let indexFrom = textToFilter.index(textToFilter.startIndex, offsetBy: textToFilter.count) let itemSuffix = item.title[indexFrom...] - + item.attributedTitle = NSMutableAttributedString(string: String(itemSuffix)) filteredResults.append(item) } } } - + tableView?.reloadData() - + if inlineMode { handleInlineFiltering() } } - + // Clean filtered results fileprivate func clearResults() { filteredResults.removeAll() tableView?.removeFromSuperview() } - + // Look for Font attribute, and if it exists, adapt to the subtitle font size fileprivate func highlightAttributesForSubtitle() -> [NSAttributedString.Key: AnyObject] { var highlightAttributesForSubtitle = [NSAttributedString.Key: AnyObject]() - + for attr in highlightAttributes { if attr.0 == NSAttributedString.Key.font { let fontName = (attr.1 as! UIFont).fontName @@ -508,10 +511,10 @@ open class SearchTextField: UITextField { highlightAttributesForSubtitle[attr.0] = attr.1 } } - + return highlightAttributesForSubtitle } - + // Handle inline behaviour func handleInlineFiltering() { if let text = self.text { @@ -526,21 +529,21 @@ open class SearchTextField: UITextField { } } } - + // MARK: - Prepare for draw table result - + fileprivate func prepareDrawTableResult() { guard let frame = self.superview?.convert(self.frame, to: UIApplication.shared.keyWindow) else { return } if let keyboardFrame = keyboardFrame { var newFrame = frame newFrame.size.height += theme.cellHeight - + if keyboardFrame.intersects(newFrame) { direction = .up } else { direction = .down } - + redrawSearchTableView() } else { if self.center.y + theme.cellHeight > UIApplication.shared.keyWindow!.frame.size.height { @@ -556,45 +559,51 @@ extension SearchTextField: UITableViewDelegate, UITableViewDataSource { public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { tableView.isHidden = !interactedWith || (filteredResults.count == 0) shadowView?.isHidden = !interactedWith || (filteredResults.count == 0) - + if maxNumberOfResults > 0 { return min(filteredResults.count, maxNumberOfResults) } else { return filteredResults.count } } - + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { var cell = tableView.dequeueReusableCell(withIdentifier: SearchTextField.cellIdentifier) - + if cell == nil { cell = UITableViewCell(style: .subtitle, reuseIdentifier: SearchTextField.cellIdentifier) } - - cell!.backgroundColor = UIColor.clear - cell!.layoutMargins = UIEdgeInsets.zero - cell!.preservesSuperviewLayoutMargins = false - cell!.textLabel?.font = theme.font - cell!.detailTextLabel?.font = UIFont(name: theme.font.fontName, size: theme.font.pointSize * fontConversionRate) - cell!.textLabel?.textColor = theme.fontColor - cell!.detailTextLabel?.textColor = theme.subtitleFontColor - - cell!.textLabel?.text = filteredResults[(indexPath as NSIndexPath).row].title - cell!.detailTextLabel?.text = filteredResults[(indexPath as NSIndexPath).row].subtitle - cell!.textLabel?.attributedText = filteredResults[(indexPath as NSIndexPath).row].attributedTitle - cell!.detailTextLabel?.attributedText = filteredResults[(indexPath as NSIndexPath).row].attributedSubtitle - - cell!.imageView?.image = filteredResults[(indexPath as NSIndexPath).row].image - - cell!.selectionStyle = .none - - return cell! - } - + + let theCell = cell! + let result = filteredResults[(indexPath as NSIndexPath).row] + + theCell.backgroundColor = UIColor.clear + theCell.layoutMargins = UIEdgeInsets.zero + theCell.preservesSuperviewLayoutMargins = false + theCell.textLabel?.font = theme.font + theCell.detailTextLabel?.font = theme.font.withSize(theme.font.pointSize * fontConversionRate) + theCell.textLabel?.textColor = theme.fontColor + theCell.detailTextLabel?.textColor = theme.subtitleFontColor + + theCell.textLabel?.text = result.title + theCell.detailTextLabel?.text = result.subtitle + theCell.textLabel?.attributedText = result.attributedTitle + theCell.detailTextLabel?.attributedText = result.attributedSubtitle + + theCell.imageView?.image = result.image + theCell.imageView?.tintColor = theme.fontColor + + theCell.accessoryType = result.accessory + theCell.selectionStyle = .none + theCell.tintColor = theme.subtitleFontColor + + return theCell + } + public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return theme.cellHeight } - + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if itemSelectionHandler == nil { self.text = filteredResults[(indexPath as NSIndexPath).row].title @@ -602,7 +611,7 @@ extension SearchTextField: UITableViewDelegate, UITableViewDataSource { let index = indexPath.row itemSelectionHandler!(filteredResults, index) } - + clearResults() } } @@ -620,7 +629,7 @@ public struct SearchTextFieldTheme { public var fontColor: UIColor public var subtitleFontColor: UIColor public var placeholderColor: UIColor? - + init(cellHeight: CGFloat, bgColor:UIColor, borderColor: UIColor, separatorColor: UIColor, font: UIFont, fontColor: UIColor, subtitleFontColor: UIColor? = nil) { self.cellHeight = cellHeight self.borderColor = borderColor @@ -630,11 +639,11 @@ public struct SearchTextFieldTheme { self.fontColor = fontColor self.subtitleFontColor = subtitleFontColor ?? fontColor } - + public static func lightTheme() -> SearchTextFieldTheme { return SearchTextFieldTheme(cellHeight: 30, bgColor: UIColor (red: 1, green: 1, blue: 1, alpha: 0.6), borderColor: UIColor (red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0), separatorColor: UIColor.clear, font: UIFont.systemFont(ofSize: 10), fontColor: UIColor.black) } - + public static func darkTheme() -> SearchTextFieldTheme { return SearchTextFieldTheme(cellHeight: 30, bgColor: UIColor (red: 0.8, green: 0.8, blue: 0.8, alpha: 0.6), borderColor: UIColor (red: 0.7, green: 0.7, blue: 0.7, alpha: 1.0), separatorColor: UIColor.clear, font: UIFont.systemFont(ofSize: 10), fontColor: UIColor.white) } @@ -643,29 +652,22 @@ public struct SearchTextFieldTheme { //////////////////////////////////////////////////////////////////////// // Filter Item -open class SearchTextFieldItem { +public struct SearchTextFieldItem { // Private vars fileprivate var attributedTitle: NSMutableAttributedString? fileprivate var attributedSubtitle: NSMutableAttributedString? - + // Public interface public var title: String public var subtitle: String? public var image: UIImage? - - public init(title: String, subtitle: String?, image: UIImage?) { + public var accessory: UITableViewCell.AccessoryType = .none + + public init(title: String, subtitle: String? = nil, image: UIImage? = nil, accessory: UITableViewCell.AccessoryType = .none) { self.title = title self.subtitle = subtitle self.image = image - } - - public init(title: String, subtitle: String?) { - self.title = title - self.subtitle = subtitle - } - - public init(title: String) { - self.title = title + self.accessory = accessory } }