Dactyloidae/mobile/ios/Client/Frontend/Widgets/SnackBar.swift
2026-06-26 21:04:09 -07:00

319 lines
11 KiB
Swift

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import SnapKit
import Shared
class SnackBarUX {
static var MaxWidth: CGFloat = 400
}
/**
* A specialized version of UIButton for use in SnackBars. These are displayed evenly
* spaced in the bottom of the bar. The main convenience of these is that you can pass
* in a callback in the constructor (although these also style themselves appropriately).
*
*``SnackButton(title: "OK", { _ in print("OK", terminator: "\n") })``
*/
class SnackButton: UIButton {
let callback: (_ bar: SnackBar) -> Void
fileprivate var bar: SnackBar!
/**
* An image to show as the background when a button is pressed. This is currently a 1x1 pixel blue color
*/
lazy var highlightImg: UIImage = {
let size = CGSize(width: 1, height: 1)
return UIImage.createWithColor(size, color: UIConstants.HighlightColor)
}()
init(title: String, accessibilityIdentifier: String, callback: @escaping (_ bar: SnackBar) -> Void) {
self.callback = callback
super.init(frame: CGRect.zero)
setTitle(title, for: UIControlState())
titleLabel?.font = DynamicFontHelper.defaultHelper.DefaultMediumFont
setBackgroundImage(highlightImg, for: .highlighted)
setTitleColor(UIConstants.HighlightText, for: .highlighted)
addTarget(self, action: #selector(SnackButton.onClick), for: .touchUpInside)
self.accessibilityIdentifier = accessibilityIdentifier
}
override init(frame: CGRect) {
self.callback = { bar in }
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func onClick() {
callback(bar)
}
}
/**
* Presents some information to the user. Can optionally include some buttons and an image. Usage:
*
* ``let bar = SnackBar(text: "This is some text in the snackbar.",
* img: UIImage(named: "bookmark"),
* buttons: [
* SnackButton(title: "OK", { _ in print("OK", terminator: "\n") }),
* SnackButton(title: "Cancel", { _ in print("Cancel", terminator: "\n") }),
* SnackButton(title: "Maybe", { _ in print("Maybe", terminator: "\n") })
* ]
* )``
*/
class SnackBar: UIView {
let imageView: UIImageView
let textLabel: UILabel
let contentView: UIView
let backgroundView: UIView
let buttonsView: Toolbar
fileprivate var buttons = [SnackButton]()
// The Constraint for the bottom of this snackbar. We use this to transition it
var bottom: Constraint?
convenience init(text: String, img: UIImage?, buttons: [SnackButton]?) {
var attributes = [String: AnyObject]()
attributes[NSFontAttributeName] = DynamicFontHelper.defaultHelper.DefaultMediumFont
attributes[NSBackgroundColorAttributeName] = UIColor.clear
let attrText = NSAttributedString(string: text, attributes: attributes)
self.init(attrText: attrText, img: img, buttons: buttons)
}
init(attrText: NSAttributedString, img: UIImage?, buttons: [SnackButton]?) {
imageView = UIImageView()
textLabel = UILabel()
contentView = UIView()
buttonsView = Toolbar()
backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: UIBlurEffectStyle.extraLight))
super.init(frame: CGRect.zero)
imageView.image = img
textLabel.attributedText = attrText
if let buttons = buttons {
for button in buttons {
addButton(button)
}
}
setup()
}
fileprivate override init(frame: CGRect) {
imageView = UIImageView()
textLabel = UILabel()
contentView = UIView()
buttonsView = Toolbar()
backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: UIBlurEffectStyle.extraLight))
super.init(frame: frame)
}
fileprivate func setup() {
textLabel.backgroundColor = nil
addSubview(backgroundView)
addSubview(contentView)
contentView.addSubview(imageView)
contentView.addSubview(textLabel)
addSubview(buttonsView)
self.backgroundColor = UIColor.clear
buttonsView.drawTopBorder = true
buttonsView.drawBottomBorder = false
buttonsView.drawSeperators = true
imageView.contentMode = UIViewContentMode.left
textLabel.font = DynamicFontHelper.defaultHelper.DefaultMediumFont
textLabel.lineBreakMode = NSLineBreakMode.byWordWrapping
textLabel.numberOfLines = 0
textLabel.backgroundColor = UIColor.clear
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let imageWidth: CGFloat
if let img = imageView.image {
imageWidth = img.size.width + UIConstants.DefaultPadding * 2
} else {
imageWidth = 0
}
self.textLabel.preferredMaxLayoutWidth = contentView.frame.width - (imageWidth + UIConstants.DefaultPadding)
super.layoutSubviews()
}
fileprivate func drawLine(_ context: CGContext, start: CGPoint, end: CGPoint) {
context.setStrokeColor(UIConstants.BorderColor.cgColor)
context.setLineWidth(1)
context.move(to: CGPoint(x: start.x, y: start.y))
context.addLine(to: CGPoint(x: end.x, y: end.y))
context.strokePath()
}
override func draw(_ rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
drawLine(context!, start: CGPoint(x: 0, y: 1), end: CGPoint(x: frame.size.width, y: 1))
}
/**
* Called to check if the snackbar should be removed or not. By default, Snackbars persist forever.
* Override this class or use a class like CountdownSnackbar if you want things expire
* - returns: true if the snackbar should be kept alive
*/
func shouldPersist(_ tab: Tab) -> Bool {
return true
}
override func updateConstraints() {
super.updateConstraints()
backgroundView.snp.remakeConstraints { make in
make.bottom.left.right.equalTo(self)
// Offset it by the width of the top border line so we can see the line from the super view
make.top.equalTo(self).offset(1)
}
contentView.snp.remakeConstraints { make in
make.top.left.right.equalTo(self).inset(UIEdgeInsets(equalInset: UIConstants.DefaultPadding))
}
if let img = imageView.image {
imageView.snp.remakeConstraints { make in
make.left.centerY.equalTo(contentView)
// To avoid doubling the padding, the textview doesn't have an inset on its left side.
// Instead, it relies on the imageView to tell it where its left side should be.
make.width.equalTo(img.size.width + UIConstants.DefaultPadding)
make.height.equalTo(img.size.height + UIConstants.DefaultPadding)
}
} else {
imageView.snp.remakeConstraints { make in
make.width.height.equalTo(0)
make.top.left.equalTo(self)
make.bottom.lessThanOrEqualTo(contentView.snp.bottom)
}
}
textLabel.snp.remakeConstraints { make in
make.top.equalTo(contentView)
make.left.equalTo(self.imageView.snp.right)
make.trailing.equalTo(contentView)
make.bottom.lessThanOrEqualTo(contentView.snp.bottom)
}
buttonsView.snp.remakeConstraints { make in
make.top.equalTo(contentView.snp.bottom).offset(UIConstants.DefaultPadding)
make.bottom.equalTo(self.snp.bottom)
make.left.right.equalTo(self)
if self.buttonsView.subviews.count > 0 {
make.height.equalTo(UIConstants.SnackbarButtonHeight)
} else {
make.height.equalTo(0)
}
}
}
var showing: Bool {
return alpha != 0 && self.superview != nil
}
/**
* Helper for animating the Snackbar showing on screen.
*/
func show() {
alpha = 1
bottom?.update(offset: 0)
}
/**
* Helper for animating the Snackbar leaving the screen.
*/
func hide() {
alpha = 0
var h = frame.height
if h == 0 {
h = UIConstants.ToolbarHeight
}
bottom?.update(offset: h)
}
fileprivate func addButton(_ snackButton: SnackButton) {
snackButton.bar = self
buttonsView.addButtons([snackButton])
buttonsView.setNeedsUpdateConstraints()
}
}
/**
* A special version of a snackbar that persists for at least a timeout. After that
* it will dismiss itself on the next page load where this tab isn't showing. As long as
* you stay on the current tab though, it will persist until you interact with it.
*/
class TimerSnackBar: SnackBar {
fileprivate var prevURL: URL?
fileprivate var timer: Timer?
fileprivate var timeout: TimeInterval
init(timeout: TimeInterval = 10, attrText: NSAttributedString, img: UIImage?, buttons: [SnackButton]?) {
self.timeout = timeout
super.init(attrText: attrText, img: img, buttons: buttons)
}
override init(frame: CGRect) {
self.timeout = 0
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
static func showAppStoreConfirmationBar(forTab tab: Tab, appStoreURL: URL) {
let msg = NSAttributedString(string: Strings.ExternalLinkAppStoreConfirmationTitle, attributes: [NSForegroundColorAttributeName: UIConstants.TableViewRowTextColor])
let bar = TimerSnackBar(attrText: msg,
img: UIImage(named: "defaultFavicon"),
buttons: [
SnackButton(title: UIConstants.OKString, accessibilityIdentifier: "ConfirmOpenInAppStore", callback: { bar in
tab.removeSnackbar(bar)
UIApplication.shared.openURL(appStoreURL)
}),
SnackButton(title: UIConstants.CancelString, accessibilityIdentifier: "CancelOpenInAppStore", callback: { bar in
tab.removeSnackbar(bar)
})
])
tab.addSnackbar(bar)
}
override func show() {
self.timer = Timer(timeInterval: timeout, target: self, selector: #selector(TimerSnackBar.SELTimerDone), userInfo: nil, repeats: false)
RunLoop.current.add(self.timer!, forMode: RunLoopMode.defaultRunLoopMode)
super.show()
}
@objc
func SELTimerDone() {
self.timer = nil
}
override func shouldPersist(_ tab: Tab) -> Bool {
if !showing {
return timer != nil
}
return super.shouldPersist(tab)
}
}