421 lines
15 KiB
Swift
421 lines
15 KiB
Swift
//
|
|
// 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))
|
|
}
|
|
}
|