// // URLBar.swift // SBrowser // // Created by James Magahern on 7/23/20. // import UIKit protocol URLBarDelegate: AnyObject { func urlBarRequestedFocusEscape(_ urlBar: URLBar) } class TextFieldWithKeyCommands: UITextField { internal var _keyCommands: [UIKeyCommand]? = [] override var keyCommands: [UIKeyCommand]? { get { _keyCommands } set { _keyCommands = newValue } } } class TextFieldControlsView: UIView { let clearButton = UIButton(frame: .zero) let autocorrectButton = UIButton(frame: .zero) static let padding = CGFloat(4.0) static let spacing = CGFloat(2.0) init() { super.init(frame: .zero) var clearButtonConfig = UIButton.Configuration.borderless() clearButtonConfig.image = .init(systemName: "xmark.circle.fill") clearButtonConfig.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration.init(pointSize: 12.0) clearButton.configuration = clearButtonConfig clearButton.tintColor = .secondaryLabel clearButton.translatesAutoresizingMaskIntoConstraints = false addSubview(clearButton) var autocorrectButtonConfig = UIButton.Configuration.borderedTinted() autocorrectButtonConfig.image = .init(systemName: "textformat") autocorrectButton.changesSelectionAsPrimaryAction = true autocorrectButton.configuration = autocorrectButtonConfig autocorrectButton.translatesAutoresizingMaskIntoConstraints = false addSubview(autocorrectButton) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func sizeThatFits(_ size: CGSize) -> CGSize { CGSize(width: (size.height * 2) + Self.spacing, height: size.height) } override func layoutSubviews() { super.layoutSubviews() let size = bounds.height autocorrectButton.frame = CGRect( x: bounds.width - size, y: 0.0, width: size, height: size ).insetBy(dx: Self.padding, dy: Self.padding) clearButton.frame = CGRect( x: autocorrectButton.frame.minX - size - Self.spacing, y: 0.0, width: size, height: size ).insetBy(dx: Self.padding, dy: Self.padding) } } class URLBar: ReliefButton { let textField = TextFieldWithKeyCommands(frame: .zero) let refreshButton = UIButton(frame: .zero) let errorButton = UIButton(frame: .zero) let documentButton = UIButton(frame: .zero) let controlsView = TextFieldControlsView() weak var delegate: URLBarDelegate? public enum LoadProgress: Equatable { case idle case complete case loading(progress: Double) case error(error: Error) public static func == (lhs: URLBar.LoadProgress, rhs: URLBar.LoadProgress) -> Bool { switch lhs { case .idle: if case .idle = rhs { return true } else { return false } case .complete: if case .complete = rhs { return true } else { return false } case let .loading(progress: mine): if case .loading(progress: let theirs) = rhs { return mine == theirs } else { return false } case .error: if case .error(error: _) = rhs { return true } else { return false } } } } public var loadProgress: LoadProgress = .idle { didSet { if oldValue != loadProgress { updateProgressIndicator() } } } override var isPointerInteractionEnabled: Bool { get { false } set {} } private let fadeMaskView = UIImageView(frame: .zero) private let progressIndicatorView = ProgressIndicatorView() private var progressIndicatorAnimating = false private let documentImage = UIImage(systemName: "ellipsis.circle") private let refreshImage = UIImage(systemName: "arrow.clockwise") private let stopImage = UIImage(systemName: "xmark") private let backgroundCornerRadius: CGFloat = 0 private let documentSeparatorView = UIView(frame: .zero) override init() { super.init() backgroundColor = .clear constrainedToSquare = false backgroundView.addSubview(progressIndicatorView) textField.backgroundColor = .clear textField.textContentType = .URL textField.keyboardType = .webSearch textField.autocorrectionType = .no textField.autocapitalizationType = .none textField.font = .systemFont(ofSize: 13.0) textField.clearButtonMode = .never textField.placeholder = "URL or search term" textField.keyCommands = [ UIKeyCommand(action: #selector(Self.downKeyPressed), input: UIKeyCommand.inputDownArrow) .prioritizeOverSystem() ] addSubview(textField) refreshButton.tintColor = .secondaryLabel refreshButton.setImage(refreshImage, for: .normal) refreshButton.isPointerInteractionEnabled = true addSubview(refreshButton) errorButton.backgroundColor = .systemRed errorButton.layer.cornerRadius = 3.0 errorButton.layer.masksToBounds = true errorButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 11.0) errorButton.setTitleColor(.white, for: .normal) errorButton.setTitle("ERR", for: .normal) errorButton.isPointerInteractionEnabled = true addSubview(errorButton) documentButton.tintColor = .secondaryLabel documentButton.setImage(documentImage, for: .normal) documentButton.isPointerInteractionEnabled = true addSubview(documentButton) documentSeparatorView.backgroundColor = .secondarySystemFill addSubview(documentSeparatorView) controlsView.autoresizingMask = [] controlsView.clearButton.addAction(.init(handler: { [textField] _ in textField.clearText() }), for: .primaryActionTriggered) controlsView.autocorrectButton.addAction(.init(handler: { [unowned self] _ in self.setAutocorrectEnabled(controlsView.autocorrectButton.isSelected) }), for: .touchUpInside) if traitCollection.userInterfaceIdiom != .mac { textField.rightView = controlsView textField.rightViewMode = .whileEditing } setErrorButtonAnimating(false) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func downKeyPressed(_ sender: Any?) { self.delegate?.urlBarRequestedFocusEscape(self) } private func setAutocorrectEnabled(_ enabled: Bool) { textField.autocorrectionType = enabled ? .yes : .no textField.reloadInputViews() } private func updateProgressIndicator() { setErrorButtonAnimating(false) if progressIndicatorAnimating { return } UIView.animate(withDuration: 0.4) { [unowned self] in switch self.loadProgress { case .idle: self.refreshButton.isHidden = true self.setErrorButtonAnimating(false) self.progressIndicatorView.alpha = 0.0 self.progressIndicatorView.progress = 0.0 case .complete: self.refreshButton.isHidden = false self.refreshButton.setImage(self.refreshImage, for: .normal) self.progressIndicatorView.progress = 1.0 self.progressIndicatorAnimating = true UIView.animate(withDuration: 0.5, delay: 0.5, options: AnimationOptions()) { self.progressIndicatorView.alpha = 0.0 } completion: { _ in // Reset back to zero self.progressIndicatorView.progress = 0.0 self.progressIndicatorAnimating = false } case .loading(let progress): self.refreshButton.isHidden = false self.refreshButton.setImage(self.stopImage, for: .normal) self.progressIndicatorView.progress = progress self.progressIndicatorView.alpha = 1.0 case .error(let error): self.refreshButton.isHidden = false self.setErrorButtonAnimating(true) self.progressIndicatorView.alpha = 0.0 self.progressIndicatorView.progress = 0.0 if let nserror = error as NSError? { self.errorButton.setTitle("\(nserror.code)", for: .normal) } else { self.errorButton.setTitle("ERR", for: .normal) } } } self.setNeedsLayout() } override func setBackgroundInverted(_ inverted: Bool) { // Do not invert. super.setBackgroundInverted(false) } override var intrinsicContentSize: CGSize { let preferredHeight = CGFloat(34) return CGSize(width: 1000.0, height: preferredHeight) } private func fadeBackgroundImageForSize(_ size: CGSize, cutoffLocation: CGFloat) -> UIImage? { guard size.width > .leastNonzeroMagnitude && size.height > .leastNonzeroMagnitude else { return nil } var image: UIImage? = nil UIGraphicsBeginImageContext(CGSize(width: size.width, height: 1.0)) if let context = UIGraphicsGetCurrentContext() { let gradientColorsArray = [ UIColor(white: 1.0, alpha: 1.0).cgColor, UIColor(white: 1.0, alpha: 1.0).cgColor, UIColor(white: 1.0, alpha: 0.08).cgColor, UIColor(white: 1.0, alpha: 0.08).cgColor ] let cutoffLocation = CGFloat.minimum(0.9, cutoffLocation) let locations: [CGFloat] = [ 0.0, cutoffLocation, cutoffLocation + 0.10, 1.0 ] if let gradient = CGGradient(colorsSpace: nil, colors: gradientColorsArray as CFArray, locations: locations) { context.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) } image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext(); } return image } private func setErrorButtonAnimating(_ animating: Bool) { let animationKey = "blinkAnimation" if animating { let blinkAnimation = CAKeyframeAnimation(keyPath: "opacity") blinkAnimation.calculationMode = .discrete blinkAnimation.values = [ 1.0, 0.1, 1.0 ] blinkAnimation.keyTimes = [ 0.0, 0.5, 1.0 ] blinkAnimation.duration = 1.0 blinkAnimation.repeatCount = .infinity self.errorButton.layer.add(blinkAnimation, forKey: animationKey) } else { self.errorButton.layer.removeAnimation(forKey: animationKey) } } override func layoutSubviews() { super.layoutSubviews() if let animationKeys = self.layer.animationKeys(), animationKeys.count > 0 { // Kill the shadowPath while animating, since we cannot animate shadow paths apparently. shadowView.layer.shadowPath = nil } progressIndicatorView.frame = backgroundView.bounds // Document button documentButton.frame = CGRect(x: 0.0, y: 0.0, width: bounds.height, height: bounds.height) // Document separator documentSeparatorView.frame = CGRect( x: documentButton.frame.maxX, y: 0.0, width: 1.0, height: bounds.height ) documentSeparatorView.frame = documentSeparatorView.frame.insetBy(dx: 0.0, dy: 3.0) // Text field controls controlsView.frame = CGRect(origin: controlsView.frame.origin, size: controlsView.sizeThatFits(bounds.size)) // Text field let textFieldPadding: CGFloat = 6.0 let textFieldOrigin = CGPoint(x: documentButton.frame.maxX + textFieldPadding, y: 0.0) textField.frame = CGRect( origin: textFieldOrigin, size: CGSize( width: bounds.width - textFieldOrigin.x - textFieldPadding, height: bounds.height ) ) var fadeCutoffLocation: CGFloat = 0.8 // Refresh button let refreshButtonSize = CGSize(width: textField.frame.height, height: textField.frame.height) refreshButton.frame = CGRect(origin: CGPoint(x: bounds.width - refreshButtonSize.width, y: 0), size: refreshButtonSize) // Refresh vs. controls view visibility let isFocused = textField.isFirstResponder if isFocused { controlsView.alpha = 1.0 refreshButton.alpha = 0.0 } else { controlsView.alpha = 0.0 refreshButton.alpha = 1.0 } // Error button if case .error(error: _) = loadProgress, !isFocused { errorButton.isHidden = false errorButton.sizeToFit() errorButton.frame = CGRect( x: refreshButton.frame.minX - errorButton.frame.width - 8.0, y: 0.0, width: errorButton.frame.width + 8.0, height: 22.0 ) errorButton.frame = errorButton.frame.centeredY(inRect: bounds) fadeCutoffLocation = (errorButton.frame.minX / bounds.width) - 0.1 } else { errorButton.isHidden = true } // Fade mask fadeMaskView.frame = textField.bounds fadeMaskView.image = fadeBackgroundImageForSize(fadeMaskView.frame.size, cutoffLocation: fadeCutoffLocation) if !isFocused { textField.mask = fadeMaskView } else { textField.mask = nil } } } class ProgressIndicatorView: UIView { public var progress: Double = 0.0 { didSet { layoutSubviews() } } private let progressFillView = UIView(frame: .zero) convenience init() { self.init(frame: .zero) progressFillView.backgroundColor = .systemBlue progressFillView.alpha = 0.3 addSubview(progressFillView) } override func layoutSubviews() { super.layoutSubviews() let width = CGFloat(progress) * bounds.width progressFillView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: bounds.height)) } }