Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -0,0 +1,237 @@
/* 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 UIKit
import Shared
import Storage
struct ActivityStreamHighlightCellUX {
static let LabelColor = UIAccessibilityDarkerSystemColorsEnabled() ? UIColor.black : UIColor(rgb: 0x353535)
static let BorderWidth: CGFloat = 0.5
static let CellSideOffset = 20
static let TitleLabelOffset = 2
static let CellTopBottomOffset = 12
static let SiteImageViewSize: CGSize = UIDevice.current.userInterfaceIdiom == .pad ? CGSize(width: 99, height: 120) : CGSize(width: 99, height: 90)
static let StatusIconSize = 12
static let FaviconSize = CGSize(width: 45, height: 45)
static let DescriptionLabelColor = UIColor(rgb: 0x919191)
static let SelectedOverlayColor = UIColor(white: 0.0, alpha: 0.25)
static let CornerRadius: CGFloat = 3
static let BorderColor = UIColor(white: 0, alpha: 0.1)
}
class ActivityStreamHighlightCell: UICollectionViewCell {
fileprivate lazy var titleLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.font = DynamicFontHelper.defaultHelper.MediumSizeHeavyWeightAS
titleLabel.textColor = ActivityStreamHighlightCellUX.LabelColor
titleLabel.textAlignment = .left
titleLabel.numberOfLines = 3
return titleLabel
}()
fileprivate lazy var descriptionLabel: UILabel = {
let descriptionLabel = UILabel()
descriptionLabel.font = DynamicFontHelper.defaultHelper.SmallSizeRegularWeightAS
descriptionLabel.textColor = ActivityStreamHighlightCellUX.DescriptionLabelColor
descriptionLabel.textAlignment = .left
descriptionLabel.numberOfLines = 1
return descriptionLabel
}()
fileprivate lazy var domainLabel: UILabel = {
let descriptionLabel = UILabel()
descriptionLabel.font = DynamicFontHelper.defaultHelper.SmallSizeRegularWeightAS
descriptionLabel.textColor = ActivityStreamHighlightCellUX.DescriptionLabelColor
descriptionLabel.textAlignment = .left
descriptionLabel.numberOfLines = 1
descriptionLabel.setContentCompressionResistancePriority(1000, for: UILayoutConstraintAxis.vertical)
return descriptionLabel
}()
lazy var siteImageView: UIImageView = {
let siteImageView = UIImageView()
siteImageView.contentMode = UIViewContentMode.scaleAspectFit
siteImageView.clipsToBounds = true
siteImageView.contentMode = UIViewContentMode.center
siteImageView.layer.cornerRadius = ActivityStreamHighlightCellUX.CornerRadius
siteImageView.layer.borderColor = ActivityStreamHighlightCellUX.BorderColor.cgColor
siteImageView.layer.borderWidth = ActivityStreamHighlightCellUX.BorderWidth
siteImageView.layer.masksToBounds = true
return siteImageView
}()
fileprivate lazy var statusIcon: UIImageView = {
let statusIcon = UIImageView()
statusIcon.contentMode = UIViewContentMode.scaleAspectFit
statusIcon.clipsToBounds = true
statusIcon.layer.cornerRadius = ActivityStreamHighlightCellUX.CornerRadius
return statusIcon
}()
fileprivate lazy var selectedOverlay: UIView = {
let selectedOverlay = UIView()
selectedOverlay.backgroundColor = ActivityStreamHighlightCellUX.SelectedOverlayColor
selectedOverlay.isHidden = true
return selectedOverlay
}()
override var isSelected: Bool {
didSet {
self.selectedOverlay.isHidden = !isSelected
}
}
override init(frame: CGRect) {
super.init(frame: frame)
layer.shouldRasterize = true
layer.rasterizationScale = UIScreen.main.scale
isAccessibilityElement = true
contentView.addSubview(siteImageView)
contentView.addSubview(descriptionLabel)
contentView.addSubview(selectedOverlay)
contentView.addSubview(titleLabel)
contentView.addSubview(statusIcon)
contentView.addSubview(domainLabel)
siteImageView.snp.makeConstraints { make in
make.top.equalTo(contentView)
make.leading.trailing.equalTo(contentView)
make.centerX.equalTo(contentView)
make.height.equalTo(ActivityStreamHighlightCellUX.SiteImageViewSize)
}
selectedOverlay.snp.makeConstraints { make in
make.edges.equalTo(contentView)
}
domainLabel.snp.makeConstraints { make in
make.leading.equalTo(siteImageView)
make.trailing.equalTo(contentView)
make.top.equalTo(siteImageView.snp.bottom).offset(5)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(siteImageView)
make.trailing.equalTo(contentView)
make.top.equalTo(domainLabel.snp.bottom).offset(5)
}
descriptionLabel.snp.makeConstraints { make in
make.leading.equalTo(statusIcon.snp.trailing).offset(ActivityStreamHighlightCellUX.TitleLabelOffset)
make.bottom.equalTo(contentView)
}
statusIcon.snp.makeConstraints { make in
make.size.equalTo(ActivityStreamHighlightCellUX.StatusIconSize)
make.centerY.equalTo(descriptionLabel.snp.centerY)
make.leading.equalTo(siteImageView)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
self.siteImageView.image = nil
contentView.backgroundColor = UIColor.clear
siteImageView.backgroundColor = UIColor.clear
}
func configureWithSite(_ site: Site) {
if let mediaURLStr = site.metadata?.mediaURL,
let mediaURL = URL(string: mediaURLStr) {
self.siteImageView.sd_setImage(with: mediaURL)
self.siteImageView.contentMode = .scaleAspectFill
} else {
let itemURL = site.tileURL
self.siteImageView.setFavicon(forSite: site, onCompletion: { [weak self] (color, url) in
if itemURL == url {
self?.siteImageView.image = self?.siteImageView.image?.createScaled(ActivityStreamHighlightCellUX.FaviconSize)
self?.siteImageView.backgroundColor = color
}
})
self.siteImageView.contentMode = .center
}
self.domainLabel.text = site.tileURL.hostSLD
self.titleLabel.text = site.title.characters.count <= 1 ? site.url : site.title
if let bookmarked = site.bookmarked, bookmarked {
self.descriptionLabel.text = Strings.HighlightBookmarkText
self.statusIcon.image = UIImage(named: "context_bookmark")
} else {
self.descriptionLabel.text = Strings.HighlightVistedText
self.statusIcon.image = UIImage(named: "context_viewed")
}
}
func configureWithPocketStory(_ pocketStory: PocketStory) {
self.siteImageView.sd_setImage(with: pocketStory.imageURL)
self.siteImageView.contentMode = .scaleAspectFill
self.domainLabel.text = pocketStory.domain
self.titleLabel.text = pocketStory.title
self.descriptionLabel.text = Strings.PocketTrendingText
self.statusIcon.image = UIImage(named: "context_pocket")
}
}
struct HighlightIntroCellUX {
static let margin: CGFloat = 20
static let foxImageWidth: CGFloat = 168
}
class HighlightIntroCell: UICollectionViewCell {
lazy var titleLabel: UILabel = {
let textLabel = UILabel()
textLabel.font = DynamicFontHelper.defaultHelper.MediumSizeBoldFontAS
textLabel.textColor = UIColor.black
textLabel.numberOfLines = 1
textLabel.adjustsFontSizeToFitWidth = true
textLabel.minimumScaleFactor = 0.8
return textLabel
}()
lazy var descriptionLabel: UILabel = {
let label = UILabel()
label.font = DynamicFontHelper.defaultHelper.MediumSizeRegularWeightAS
label.textColor = UIColor.darkGray
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(titleLabel)
contentView.addSubview(descriptionLabel)
titleLabel.text = Strings.HighlightIntroTitle
descriptionLabel.text = Strings.HighlightIntroDescription
let titleInsets = UIEdgeInsets(top: HighlightIntroCellUX.margin, left: 0, bottom: 0, right: 0)
titleLabel.snp.makeConstraints { make in
make.leading.top.trailing.equalTo(self.contentView).inset(titleInsets)
}
descriptionLabel.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(HighlightIntroCellUX.margin/2)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View file

@ -0,0 +1,280 @@
/* 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/. */
// This code is loosely based on https://github.com/Antol/APAutocompleteTextField
import UIKit
import Shared
/// Delegate for the text field events. Since AutocompleteTextField owns the UITextFieldDelegate,
/// callers must use this instead.
protocol AutocompleteTextFieldDelegate: class {
func autocompleteTextField(_ autocompleteTextField: AutocompleteTextField, didEnterText text: String)
func autocompleteTextFieldShouldReturn(_ autocompleteTextField: AutocompleteTextField) -> Bool
func autocompleteTextFieldShouldClear(_ autocompleteTextField: AutocompleteTextField) -> Bool
func autocompleteTextFieldDidBeginEditing(_ autocompleteTextField: AutocompleteTextField)
}
struct AutocompleteTextFieldUX {
static let HighlightColor = UIColor(rgb: 0xccdded)
}
class AutocompleteTextField: UITextField, UITextFieldDelegate {
var autocompleteDelegate: AutocompleteTextFieldDelegate?
// AutocompleteTextLabel repersents the actual autocomplete text.
// The textfields "text" property only contains the entered text, while this label holds the autocomplete text
// This makes sure that the autocomplete doesnt mess with keyboard suggestions provided by third party keyboards.
private var autocompleteTextLabel: UILabel?
private var hideCursor: Bool = false
var isSelectionActive: Bool {
return autocompleteTextLabel != nil
}
// This variable is a solution to get the right behavior for refocusing
// the AutocompleteTextField. The initial transition into Overlay Mode
// doesn't involve the user interacting with AutocompleteTextField.
// Thus, we update shouldApplyCompletion in touchesBegin() to reflect whether
// the highlight is active and then the text field is updated accordingly
// in touchesEnd() (eg. applyCompletion() is called or not)
fileprivate var notifyTextChanged: (() -> Void)?
private var lastReplacement: String?
var highlightColor = AutocompleteTextFieldUX.HighlightColor
override var text: String? {
didSet {
super.text = text
self.textDidChange(self)
}
}
override var accessibilityValue: String? {
get {
return (self.text ?? "") + (self.autocompleteTextLabel?.text ?? "")
}
set(value) {
super.accessibilityValue = value
}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
fileprivate func commonInit() {
super.delegate = self
super.addTarget(self, action: #selector(AutocompleteTextField.textDidChange(_:)), for: UIControlEvents.editingChanged)
notifyTextChanged = debounce(0.1, action: {
if self.isEditing {
self.autocompleteDelegate?.autocompleteTextField(self, didEnterText: self.normalizeString(self.text ?? ""))
}
})
}
override var keyCommands: [UIKeyCommand]? {
return [
UIKeyCommand(input: UIKeyInputLeftArrow, modifierFlags: .init(rawValue: 0), action: #selector(self.handleKeyCommand(sender:))),
UIKeyCommand(input: UIKeyInputRightArrow, modifierFlags: .init(rawValue: 0), action: #selector(self.handleKeyCommand(sender:)))
]
}
func handleKeyCommand(sender: UIKeyCommand) {
switch sender.input {
case UIKeyInputLeftArrow:
if isSelectionActive {
applyCompletion()
// Set the current position to the beginning of the text.
selectedTextRange = textRange(from: beginningOfDocument, to: beginningOfDocument)
} else if let range = selectedTextRange {
if range.start == beginningOfDocument {
return
}
guard let cursorPosition = position(from: range.start, offset: -1) else {
return
}
selectedTextRange = textRange(from: cursorPosition, to: cursorPosition)
}
return
case UIKeyInputRightArrow:
if isSelectionActive {
applyCompletion()
// Set the current position to the end of the text.
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
} else if let range = selectedTextRange {
if range.end == endOfDocument {
return
}
guard let cursorPosition = position(from: range.end, offset: 1) else {
return
}
selectedTextRange = textRange(from: cursorPosition, to: cursorPosition)
}
return
default:
return
}
}
func highlightAll() {
let text = self.text
self.text = ""
setAutocompleteSuggestion(text ?? "")
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
}
fileprivate func normalizeString(_ string: String) -> String {
return string.lowercased().stringByTrimmingLeadingCharactersInSet(CharacterSet.whitespaces)
}
/// Commits the completion by setting the text and removing the highlight.
fileprivate func applyCompletion() {
// Clear the current completion, then set the text without the attributed style.
let text = (self.text ?? "") + (self.autocompleteTextLabel?.text ?? "")
removeCompletion()
self.text = text
hideCursor = false
// Move the cursor to the end of the completion.
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
}
/// Removes the autocomplete-highlighted
fileprivate func removeCompletion() {
autocompleteTextLabel?.removeFromSuperview()
autocompleteTextLabel = nil
}
// `shouldChangeCharactersInRange` is called before the text changes, and textDidChange is called after.
// Since the text has changed, remove the completion here, and textDidChange will fire the callback to
// get the new autocompletion.
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
lastReplacement = string
return true
}
func setAutocompleteSuggestion(_ suggestion: String?) {
let text = self.text ?? ""
guard let suggestion = suggestion, isEditing && markedTextRange == nil else {
hideCursor = false
return
}
let normalized = normalizeString(text)
guard suggestion.startsWith(normalized) && normalized.characters.count < suggestion.characters.count else {
hideCursor = false
return
}
let suggestionText = suggestion.substring(from: suggestion.characters.index(suggestion.startIndex, offsetBy: normalized.characters.count))
let autocompleteText = NSMutableAttributedString(string: suggestionText)
autocompleteText.addAttribute(NSBackgroundColorAttributeName, value: highlightColor, range: NSRange(location: 0, length: suggestionText.characters.count))
autocompleteTextLabel?.removeFromSuperview() // should be nil. But just in case
autocompleteTextLabel = createAutocompleteLabelWith(autocompleteText)
if let l = autocompleteTextLabel {
addSubview(l)
hideCursor = true
forceResetCursor()
}
}
override func caretRect(for position: UITextPosition) -> CGRect {
return hideCursor ? CGRect.zero : super.caretRect(for: position)
}
private func createAutocompleteLabelWith(_ autocompleteText: NSAttributedString) -> UILabel {
let label = UILabel()
var frame = self.bounds
label.attributedText = autocompleteText
label.font = self.font
label.accessibilityIdentifier = "autocomplete"
label.backgroundColor = self.backgroundColor
label.textColor = self.textColor
let enteredTextSize = self.attributedText?.boundingRect(with: self.frame.size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil)
frame.origin.x = (enteredTextSize?.width.rounded() ?? 0)
frame.size.width = self.frame.size.width - frame.origin.x
frame.size.height = self.frame.size.height - 1
label.frame = frame
return label
}
func textFieldDidBeginEditing(_ textField: UITextField) {
autocompleteDelegate?.autocompleteTextFieldDidBeginEditing(self)
}
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
applyCompletion()
return true
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
applyCompletion()
return autocompleteDelegate?.autocompleteTextFieldShouldReturn(self) ?? true
}
func textFieldShouldClear(_ textField: UITextField) -> Bool {
removeCompletion()
return autocompleteDelegate?.autocompleteTextFieldShouldClear(self) ?? true
}
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
// Clear the autocompletion if any provisionally inserted text has been
// entered (e.g., a partial composition from a Japanese keyboard).
removeCompletion()
super.setMarkedText(markedText, selectedRange: selectedRange)
}
func textDidChange(_ textField: UITextField) {
hideCursor = autocompleteTextLabel != nil
removeCompletion()
let isAtEnd = selectedTextRange?.start == endOfDocument
let isEmpty = lastReplacement?.isEmpty ?? true
if !isEmpty, isAtEnd, markedTextRange == nil {
notifyTextChanged?()
} else {
hideCursor = false
}
}
// Reset the cursor to the end of the text field.
// This forces `caretRect(for position: UITextPosition)` to be called which will decide if we should show the cursor
// This exists because ` caretRect(for position: UITextPosition)` is not called after we apply an autocompletion.
private func forceResetCursor() {
selectedTextRange = nil
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
}
override func deleteBackward() {
lastReplacement = nil
hideCursor = false
if isSelectionActive {
removeCompletion()
forceResetCursor()
} else {
super.deleteBackward()
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
applyCompletion()
super.touchesBegan(touches, with: event)
}
}

View file

@ -0,0 +1,123 @@
/* 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
enum ChevronDirection {
case left
case up
case right
case down
}
enum ChevronStyle {
case angular
case rounded
}
class ChevronView: UIView {
fileprivate let Padding: CGFloat = 2.5
fileprivate var direction = ChevronDirection.right
fileprivate var lineCapStyle = CGLineCap.round
fileprivate var lineJoinStyle = CGLineJoin.round
var lineWidth: CGFloat = 3.0
var style: ChevronStyle = .rounded {
didSet {
switch style {
case .rounded:
lineCapStyle = CGLineCap.round
lineJoinStyle = CGLineJoin.round
case .angular:
lineCapStyle = CGLineCap.butt
lineJoinStyle = CGLineJoin.miter
}
}
}
init(direction: ChevronDirection) {
super.init(frame: CGRect.zero)
self.direction = direction
if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft {
if direction == .left {
self.direction = .right
} else if direction == .right {
self.direction = .left
}
}
self.backgroundColor = UIColor.clear
self.contentMode = UIViewContentMode.redraw
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
let strokeLength = (rect.size.height / 2) - Padding
let path: UIBezierPath
switch direction {
case .left:
path = drawLeftChevronAt(CGPoint(x: rect.size.width - (strokeLength + Padding), y: strokeLength + Padding), strokeLength: strokeLength)
case .up:
path = drawUpChevronAt(CGPoint(x: (rect.size.width - Padding) - strokeLength, y: (strokeLength / 2) + Padding), strokeLength: strokeLength)
case .right:
path = drawRightChevronAt(CGPoint(x: rect.size.width - Padding, y: strokeLength + Padding), strokeLength: strokeLength)
case .down:
path = drawDownChevronAt(CGPoint(x: (rect.size.width - Padding) - strokeLength, y: (strokeLength * 1.5) + Padding), strokeLength: strokeLength)
}
tintColor.set()
// The line thickness needs to be proportional to the distance from the arrow head to the tips. Making it half seems about right.
path.lineCapStyle = lineCapStyle
path.lineJoinStyle = lineJoinStyle
path.lineWidth = lineWidth
path.stroke()
}
fileprivate func drawUpChevronAt(_ origin: CGPoint, strokeLength: CGFloat) -> UIBezierPath {
return drawChevron(CGPoint(x: origin.x-strokeLength, y: origin.y+strokeLength),
head: CGPoint(x: origin.x, y: origin.y),
rightTip: CGPoint(x: origin.x+strokeLength, y: origin.y+strokeLength))
}
fileprivate func drawDownChevronAt(_ origin: CGPoint, strokeLength: CGFloat) -> UIBezierPath {
return drawChevron(CGPoint(x: origin.x-strokeLength, y: origin.y-strokeLength),
head: CGPoint(x: origin.x, y: origin.y),
rightTip: CGPoint(x: origin.x+strokeLength, y: origin.y-strokeLength))
}
fileprivate func drawLeftChevronAt(_ origin: CGPoint, strokeLength: CGFloat) -> UIBezierPath {
return drawChevron(CGPoint(x: origin.x+strokeLength, y: origin.y-strokeLength),
head: CGPoint(x: origin.x, y: origin.y),
rightTip: CGPoint(x: origin.x+strokeLength, y: origin.y+strokeLength))
}
fileprivate func drawRightChevronAt(_ origin: CGPoint, strokeLength: CGFloat) -> UIBezierPath {
return drawChevron(CGPoint(x: origin.x-strokeLength, y: origin.y+strokeLength),
head: CGPoint(x: origin.x, y: origin.y),
rightTip: CGPoint(x: origin.x-strokeLength, y: origin.y-strokeLength))
}
fileprivate func drawChevron(_ leftTip: CGPoint, head: CGPoint, rightTip: CGPoint) -> UIBezierPath {
let path = UIBezierPath()
// Left tip
path.move(to: leftTip)
// Arrow head
path.addLine(to: head)
// Right tip
path.addLine(to: rightTip)
return path
}
}

View file

@ -0,0 +1,55 @@
/* 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
private struct ErrorToastDefaultUX {
static let cornerRadius: CGFloat = 40
static let fillColor = UIColor(red: 186/255, green: 32/255, blue: 36/255, alpha: 1)
static let margins = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
static let textColor = UIColor.white
}
class ErrorToast: UIView {
lazy var textLabel: UILabel = {
let label = UILabel()
label.textColor = ErrorToastDefaultUX.textColor
label.textAlignment = .center
label.numberOfLines = 0
return label
}()
var cornerRadius: CGFloat = ErrorToastDefaultUX.cornerRadius {
didSet {
setNeedsDisplay()
}
}
var fillColor: UIColor = ErrorToastDefaultUX.fillColor {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = false
addSubview(textLabel)
textLabel.snp.makeConstraints { make in
make.edges.equalTo(self).inset(ErrorToastDefaultUX.margins)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
fillColor.setFill()
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
path.fill()
}
}

View file

@ -0,0 +1,187 @@
/* 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/. */
// ADAPTED FROM:
//
// GradientProgressBar.swift
// GradientProgressBar
//
// Created by Felix Mau on 01.03.17.
// Copyright © 2017 Felix Mau. All rights reserved.
//
import Foundation
import UIKit
open class GradientProgressBar: UIProgressView {
private struct DefaultValues {
static let backgroundColor = UIColor.clear
static let animationDuration = 0.2 // CALayer default animation duration
}
var gradientColors: [CGColor] = []
// Alpha mask for visible part of gradient.
private var alphaMaskLayer: CALayer = CALayer()
// Gradient layer.
open var gradientLayer: CAGradientLayer = CAGradientLayer()
// Duration for "setProgress(animated: true)"
open var animationDuration = DefaultValues.animationDuration
// Workaround to handle orientation change, as "layoutSubviews()" gets triggered each time
// the progress value is changed.
override open var bounds: CGRect {
didSet {
updateAlphaMaskLayerWidth()
}
}
// Update layer mask on direct changes to progress value.
override open var progress: Float {
didSet {
updateAlphaMaskLayerWidth()
}
}
func setGradientColors(startColor: UIColor, endColor: UIColor) {
gradientColors = [startColor, endColor, startColor, endColor, startColor, endColor, startColor].map { $0.cgColor }
gradientLayer.colors = gradientColors
}
func commonInit() {
setupProgressViewColors()
setupAlphaMaskLayer()
setupGradientLayer()
layer.insertSublayer(gradientLayer, at: 0)
updateAlphaMaskLayerWidth()
}
override public init(frame: CGRect) {
gradientColors = []
super.init(frame: frame)
commonInit()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
// MARK: - Setup UIProgressView
private func setupProgressViewColors() {
backgroundColor = DefaultValues.backgroundColor
trackTintColor = .clear
progressTintColor = .clear
}
// MARK: - Setup layers
private func setupAlphaMaskLayer() {
alphaMaskLayer.frame = bounds
alphaMaskLayer.cornerRadius = 3
alphaMaskLayer.anchorPoint = CGPoint(x: 0, y: 0)
alphaMaskLayer.position = CGPoint(x: 0, y: 0)
alphaMaskLayer.backgroundColor = UIColor.white.cgColor
}
private func setupGradientLayer() {
// Apply "alphaMaskLayer" as a mask to the gradient layer in order to show only parts of the current "progress"
gradientLayer.mask = alphaMaskLayer
gradientLayer.frame = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: bounds.size.width * 2, height: bounds.size.height)
gradientLayer.colors = gradientColors
gradientLayer.locations = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.0]
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 0)
gradientLayer.drawsAsynchronously = true
}
func hideProgressBar() {
guard progress == 1 else {
return
}
CATransaction.begin()
let moveAnimation = CABasicAnimation(keyPath: "position")
moveAnimation.duration = DefaultValues.animationDuration
moveAnimation.fromValue = gradientLayer.position
moveAnimation.toValue = CGPoint(x: gradientLayer.frame.width, y: gradientLayer.position.y)
moveAnimation.fillMode = kCAFillModeForwards
moveAnimation.isRemovedOnCompletion = false
CATransaction.setCompletionBlock {
self.resetProgressBar()
}
gradientLayer.add(moveAnimation, forKey: "position")
CATransaction.commit()
}
func resetProgressBar() {
// Call on super instead so no animation layers are created
super.setProgress(0, animated: false)
isHidden = true // The URLBar will unhide the view before starting the next animation.
}
override open func layoutSubviews() {
super.layoutSubviews()
self.gradientLayer.frame = CGRect(x: bounds.origin.x - 4, y: bounds.origin.y, width: bounds.size.width * 2, height: bounds.size.height)
}
func animateGradient() {
let gradientChangeAnimation = CABasicAnimation(keyPath: "locations")
gradientChangeAnimation.duration = DefaultValues.animationDuration * 4
gradientChangeAnimation.toValue = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.0]
gradientChangeAnimation.fromValue = [0.0, 0.0, 0.0, 0.2, 0.4, 0.6, 0.8]
gradientChangeAnimation.fillMode = kCAFillModeForwards
gradientChangeAnimation.isRemovedOnCompletion = false
gradientChangeAnimation.repeatCount = .infinity
gradientLayer.add(gradientChangeAnimation, forKey: "colorChange")
}
// MARK: - Update gradient
open func updateAlphaMaskLayerWidth(animated: Bool = false) {
CATransaction.begin()
// Workaround for non animated progress change
// Source: https://stackoverflow.com/a/16381287/3532505
CATransaction.setAnimationDuration(animated ? DefaultValues.animationDuration : 0.0)
alphaMaskLayer.frame = bounds.updateWidth(byPercentage: CGFloat(progress))
if progress == 1 {
// Delay calling hide until the last animation has completed
CATransaction.setCompletionBlock({
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DefaultValues.animationDuration, execute: {
self.hideProgressBar()
})
})
}
CATransaction.commit()
}
override open func setProgress(_ progress: Float, animated: Bool) {
if progress < self.progress && self.progress != 1 {
return
}
// Setup animations
gradientLayer.removeAnimation(forKey: "position")
if gradientLayer.animation(forKey: "colorChange") == nil {
animateGradient()
}
super.setProgress(progress, animated: animated)
updateAlphaMaskLayerWidth(animated: animated)
}
}
extension CGRect {
func updateWidth(byPercentage percentage: CGFloat) -> CGRect {
return CGRect(x: origin.x, y: origin.y, width: size.width * percentage, height: size.height)
}
}

View file

@ -0,0 +1,70 @@
/* 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 SnapKit
import Shared
private struct HistoryBackButtonUX {
static let HistoryHistoryBackButtonHeaderChevronInset: CGFloat = 10
static let HistoryHistoryBackButtonHeaderChevronSize: CGFloat = 20
static let HistoryHistoryBackButtonHeaderChevronLineWidth: CGFloat = 3.0
}
class HistoryBackButton: UIButton {
lazy var title: UILabel = {
let label = UILabel()
label.textColor = UIConstants.HighlightBlue
label.text = Strings.HistoryBackButtonTitle
return label
}()
lazy var chevron: ChevronView = {
let chevron = ChevronView(direction: .left)
chevron.tintColor = UIConstants.HighlightBlue
chevron.lineWidth = HistoryBackButtonUX.HistoryHistoryBackButtonHeaderChevronLineWidth
return chevron
}()
lazy var topBorder: UIView = self.createBorderView()
lazy var bottomBorder: UIView = self.createBorderView()
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
addSubview(topBorder)
addSubview(chevron)
addSubview(title)
backgroundColor = UIColor.white
chevron.snp.makeConstraints { make in
make.leading.equalTo(self).offset(HistoryBackButtonUX.HistoryHistoryBackButtonHeaderChevronInset)
make.centerY.equalTo(self)
make.size.equalTo(HistoryBackButtonUX.HistoryHistoryBackButtonHeaderChevronSize)
}
title.snp.makeConstraints { make in
make.leading.equalTo(chevron.snp.trailing).offset(HistoryBackButtonUX.HistoryHistoryBackButtonHeaderChevronInset)
make.trailing.greaterThanOrEqualTo(self).offset(-HistoryBackButtonUX.HistoryHistoryBackButtonHeaderChevronInset)
make.centerY.equalTo(self)
}
topBorder.snp.makeConstraints { make in
make.leading.trailing.equalTo(self)
make.top.equalTo(self).offset(-0.5)
make.height.equalTo(0.5)
}
}
fileprivate func createBorderView() -> UIView {
let view = UIView()
view.backgroundColor = SiteTableViewControllerUX.HeaderBorderColor
return view
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View file

@ -0,0 +1,49 @@
/* 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 UIKit
/// A transparent view with a rectangular border with rounded corners, stroked
/// with a semi-transparent white border.
class InnerStrokedView: UIView {
var color = UIColor.white.withAlphaComponent(0.2) {
didSet {
setNeedsDisplay()
}
}
var strokeWidth: CGFloat = 1.0 {
didSet {
setNeedsDisplay()
}
}
var cornerRadius: CGFloat = 4 {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.clear
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
let halfWidth = strokeWidth / 2 as CGFloat
let path = UIBezierPath(roundedRect: CGRect(x: halfWidth,
y: halfWidth,
width: rect.width - strokeWidth,
height: rect.height - strokeWidth),
cornerRadius: cornerRadius)
color.setStroke()
path.lineWidth = strokeWidth
path.stroke()
}
}

View file

@ -0,0 +1,14 @@
/* 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/. */
/**
* Button whose insets are included in its intrinsic size.
*/
class InsetButton: UIButton {
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + titleEdgeInsets.left + titleEdgeInsets.right,
height: size.height + titleEdgeInsets.top + titleEdgeInsets.bottom)
}
}

View file

@ -0,0 +1,387 @@
/* 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 UIKit
import SnapKit
import Storage
protocol LoginTableViewCellDelegate: class {
func didSelectOpenAndFillForCell(_ cell: LoginTableViewCell)
func shouldReturnAfterEditingDescription(_ cell: LoginTableViewCell) -> Bool
func infoItemForCell(_ cell: LoginTableViewCell) -> InfoItem?
}
private struct LoginTableViewCellUX {
static let highlightedLabelFont = UIFont.systemFont(ofSize: 12)
static let highlightedLabelTextColor = UIConstants.SystemBlueColor
static let highlightedLabelEditingTextColor = UIConstants.TableViewHeaderTextColor
static let descriptionLabelFont = UIFont.systemFont(ofSize: 16)
static let descriptionLabelTextColor = UIColor.black
static let HorizontalMargin: CGFloat = 14
static let IconImageSize: CGFloat = 34
static let indentWidth: CGFloat = 44
static let IndentAnimationDuration: TimeInterval = 0.2
static let editingDescriptionIndent: CGFloat = IconImageSize + HorizontalMargin
}
enum LoginTableViewCellStyle {
case iconAndBothLabels
case noIconAndBothLabels
case iconAndDescriptionLabel
}
class LoginTableViewCell: UITableViewCell {
fileprivate let labelContainer = UIView()
weak var delegate: LoginTableViewCellDelegate?
// In order for context menu handling, this is required
override var canBecomeFirstResponder: Bool {
return true
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
guard let item = delegate?.infoItemForCell(self) else {
return false
}
// Menu actions for password
if item == .passwordItem {
let showRevealOption = self.descriptionLabel.isSecureTextEntry ? (action == MenuHelper.SelectorReveal) : (action == MenuHelper.SelectorHide)
return action == MenuHelper.SelectorCopy || showRevealOption
}
// Menu actions for Website
if item == .websiteItem {
return action == MenuHelper.SelectorCopy || action == MenuHelper.SelectorOpenAndFill
}
// Menu actions for Username
if item == .usernameItem {
return action == MenuHelper.SelectorCopy
}
return false
}
lazy var descriptionLabel: UITextField = {
let label = UITextField()
label.font = LoginTableViewCellUX.descriptionLabelFont
label.textColor = LoginTableViewCellUX.descriptionLabelTextColor
label.textAlignment = .left
label.backgroundColor = UIColor.white
label.isUserInteractionEnabled = false
label.autocapitalizationType = .none
label.autocorrectionType = .no
label.accessibilityElementsHidden = true
label.adjustsFontSizeToFitWidth = false
label.delegate = self
label.isAccessibilityElement = true
return label
}()
// Exposing this label as internal/public causes the Xcode 7.2.1 compiler optimizer to
// produce a EX_BAD_ACCESS error when dequeuing the cell. For now, this label is made private
// and the text property is exposed using a get/set property below.
fileprivate lazy var highlightedLabel: UILabel = {
let label = UILabel()
label.font = LoginTableViewCellUX.highlightedLabelFont
label.textColor = LoginTableViewCellUX.highlightedLabelTextColor
label.textAlignment = .left
label.backgroundColor = UIColor.white
label.numberOfLines = 1
return label
}()
fileprivate lazy var iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.backgroundColor = UIColor.white
imageView.contentMode = .scaleAspectFit
return imageView
}()
fileprivate var showingIndent: Bool = false
fileprivate var customIndentView = UIView()
fileprivate var customCheckmarkIcon = UIImageView(image: UIImage(named: "loginUnselected"))
/// Override the default accessibility label since it won't include the description by default
/// since it's a UITextField acting as a label.
override var accessibilityLabel: String? {
get {
if descriptionLabel.isSecureTextEntry {
return highlightedLabel.text ?? ""
} else {
return "\(highlightedLabel.text ?? ""), \(descriptionLabel.text ?? "")"
}
}
set {
// Ignore sets
}
}
var style: LoginTableViewCellStyle = .iconAndBothLabels {
didSet {
if style != oldValue {
configureLayoutForStyle(style)
}
}
}
var descriptionTextSize: CGSize? {
guard let descriptionText = descriptionLabel.text else {
return nil
}
let attributes = [
NSFontAttributeName: LoginTableViewCellUX.descriptionLabelFont
]
return descriptionText.size(attributes: attributes)
}
var displayDescriptionAsPassword: Bool = false {
didSet {
descriptionLabel.isSecureTextEntry = displayDescriptionAsPassword
}
}
var editingDescription: Bool = false {
didSet {
if editingDescription != oldValue {
descriptionLabel.isUserInteractionEnabled = editingDescription
highlightedLabel.textColor = editingDescription ?
LoginTableViewCellUX.highlightedLabelEditingTextColor : LoginTableViewCellUX.highlightedLabelTextColor
// Trigger a layout configuration if we changed to editing/not editing the description.
configureLayoutForStyle(self.style)
}
}
}
var highlightedLabelTitle: String? {
get {
return highlightedLabel.text
}
set(newTitle) {
highlightedLabel.text = newTitle
}
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
indentationWidth = 0
selectionStyle = .none
contentView.backgroundColor = UIColor.white
labelContainer.backgroundColor = UIColor.white
labelContainer.addSubview(highlightedLabel)
labelContainer.addSubview(descriptionLabel)
contentView.addSubview(iconImageView)
contentView.addSubview(labelContainer)
customIndentView.addSubview(customCheckmarkIcon)
addSubview(customIndentView)
configureLayoutForStyle(self.style)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
delegate = nil
descriptionLabel.isSecureTextEntry = false
descriptionLabel.keyboardType = .default
descriptionLabel.returnKeyType = .default
descriptionLabel.isUserInteractionEnabled = false
}
override func layoutSubviews() {
super.layoutSubviews()
// Adjust indent frame
var indentFrame = CGRect(
origin: CGPoint.zero,
size: CGSize(width: LoginTableViewCellUX.indentWidth, height: frame.height))
if !showingIndent {
indentFrame.origin.x = -LoginTableViewCellUX.indentWidth
}
customIndentView.frame = indentFrame
customCheckmarkIcon.frame.center = CGPoint(x: indentFrame.width / 2, y: indentFrame.height / 2)
// Adjust content view frame based on indent
var contentFrame = self.contentView.frame
contentFrame.origin.x += showingIndent ? LoginTableViewCellUX.indentWidth : 0
contentView.frame = contentFrame
}
fileprivate func configureLayoutForStyle(_ style: LoginTableViewCellStyle) {
switch style {
case .iconAndBothLabels:
iconImageView.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.left.equalTo(contentView).offset(LoginTableViewCellUX.HorizontalMargin)
make.height.width.equalTo(LoginTableViewCellUX.IconImageSize)
}
labelContainer.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.right.equalTo(contentView).offset(-LoginTableViewCellUX.HorizontalMargin)
make.left.equalTo(iconImageView.snp.right).offset(LoginTableViewCellUX.HorizontalMargin)
}
highlightedLabel.snp.remakeConstraints { make in
make.left.top.equalTo(labelContainer)
make.bottom.equalTo(descriptionLabel.snp.top)
make.width.equalTo(labelContainer)
}
descriptionLabel.snp.remakeConstraints { make in
make.left.bottom.equalTo(labelContainer)
make.top.equalTo(highlightedLabel.snp.bottom)
make.width.equalTo(labelContainer)
}
case .iconAndDescriptionLabel:
iconImageView.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.left.equalTo(contentView).offset(LoginTableViewCellUX.HorizontalMargin)
make.height.width.equalTo(LoginTableViewCellUX.IconImageSize)
}
labelContainer.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.right.equalTo(contentView).offset(-LoginTableViewCellUX.HorizontalMargin)
make.left.equalTo(iconImageView.snp.right).offset(LoginTableViewCellUX.HorizontalMargin)
}
highlightedLabel.snp.remakeConstraints { make in
make.height.width.equalTo(0)
}
descriptionLabel.snp.remakeConstraints { make in
make.top.left.bottom.equalTo(labelContainer)
make.width.equalTo(labelContainer)
}
case .noIconAndBothLabels:
// Currently we only support modifying the description for this layout which is why
// we factor in the editingOffset when calculating the constraints.
let editingOffset = editingDescription ? LoginTableViewCellUX.editingDescriptionIndent : 0
iconImageView.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.left.equalTo(contentView).offset(LoginTableViewCellUX.HorizontalMargin)
make.height.width.equalTo(0)
}
labelContainer.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.right.equalTo(contentView).offset(-LoginTableViewCellUX.HorizontalMargin)
make.left.equalTo(iconImageView.snp.right).offset(editingOffset)
}
highlightedLabel.snp.remakeConstraints { make in
make.left.top.equalTo(labelContainer)
make.bottom.equalTo(descriptionLabel.snp.top)
make.width.equalTo(labelContainer)
}
descriptionLabel.snp.remakeConstraints { make in
make.left.bottom.equalTo(labelContainer)
make.top.equalTo(highlightedLabel.snp.bottom)
make.width.equalTo(labelContainer)
}
}
setNeedsUpdateConstraints()
}
override func setEditing(_ editing: Bool, animated: Bool) {
showingIndent = editing
let adjustConstraints = { [unowned self] in
// Shift over content view
var contentFrame = self.contentView.frame
contentFrame.origin.x += editing ? LoginTableViewCellUX.indentWidth : -LoginTableViewCellUX.indentWidth
self.contentView.frame = contentFrame
// Shift over custom indent view
var indentFrame = self.customIndentView.frame
indentFrame.origin.x += editing ? LoginTableViewCellUX.indentWidth : -LoginTableViewCellUX.indentWidth
self.customIndentView.frame = indentFrame
}
animated ? UIView.animate(withDuration: LoginTableViewCellUX.IndentAnimationDuration, animations: adjustConstraints) : adjustConstraints()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
customCheckmarkIcon.image = UIImage(named: selected ? "loginSelected" : "loginUnselected")
}
}
// MARK: - Menu Selectors
extension LoginTableViewCell: MenuHelperInterface {
func menuHelperReveal() {
displayDescriptionAsPassword = false
}
func menuHelperSecure() {
displayDescriptionAsPassword = true
}
func menuHelperCopy() {
// Copy description text to clipboard
UIPasteboard.general.string = descriptionLabel.text
}
func menuHelperOpenAndFill() {
delegate?.didSelectOpenAndFillForCell(self)
}
}
// MARK: - Cell Decorators
extension LoginTableViewCell {
func updateCellWithLogin(_ login: LoginData) {
descriptionLabel.text = login.hostname
highlightedLabel.text = login.username
iconImageView.image = UIImage(named: "faviconFox")
}
}
extension LoginTableViewCell: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return self.delegate?.shouldReturnAfterEditingDescription(self) ?? true
}
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
if descriptionLabel.isSecureTextEntry {
displayDescriptionAsPassword = false
}
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
if descriptionLabel.isSecureTextEntry {
displayDescriptionAsPassword = true
}
}
}

View file

@ -0,0 +1,496 @@
/* 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 Storage
import SnapKit
import Shared
private struct PhotonActionSheetUX {
static let MaxWidth: CGFloat = 414
static let Padding: CGFloat = 10
static let SectionVerticalPadding: CGFloat = 13
static let HeaderHeight: CGFloat = 80
static let RowHeight: CGFloat = 44
static let LabelColor = UIAccessibilityDarkerSystemColorsEnabled() ? UIColor.black : UIColor(rgb: 0x353535)
static let DescriptionLabelColor = UIColor(rgb: 0x919191)
static let PlaceholderImage = UIImage(named: "defaultTopSiteIcon")
static let BorderWidth: CGFloat = 0.5
static let BorderColor = UIColor(white: 0, alpha: 0.1)
static let CornerRadius: CGFloat = 10
static let SiteImageViewSize = 52
static let IconSize = CGSize(width: 24, height: 24)
static let HeaderName = "PhotonActionSheetHeaderView"
static let CellName = "PhotonActionSheetCell"
static let CancelButtonHeight: CGFloat = 56
static let TablePadding: CGFloat = 6
}
public struct PhotonActionSheetItem {
public fileprivate(set) var title: String
public fileprivate(set) var iconString: String
public fileprivate(set) var isEnabled: Bool // Used by toggles like nightmode to switch tint color
public fileprivate(set) var handler: ((PhotonActionSheetItem) -> Void)?
init(title: String, iconString: String, isEnabled: Bool = false, handler: ((PhotonActionSheetItem) -> Void)?) {
self.title = title
self.iconString = iconString
self.isEnabled = isEnabled
self.handler = handler
}
}
private enum PresentationStyle {
case centered // used in the home panels
case bottom // used to display the menu
}
class PhotonActionSheet: UIViewController, UITableViewDelegate, UITableViewDataSource, UIGestureRecognizerDelegate {
fileprivate(set) var actions: [[PhotonActionSheetItem]]
private var site: Site?
private let style: PresentationStyle
private lazy var showCancelButton: Bool = {
return self.style == .bottom && self.modalPresentationStyle != .popover
}()
var tableView = UITableView(frame: CGRect.zero, style: .grouped)
private var tintColor = UIColor(rgb: 0x272727)
private var outerScrollView = UIScrollView()
lazy var tapRecognizer: UITapGestureRecognizer = {
let tapRecognizer = UITapGestureRecognizer()
tapRecognizer.addTarget(self, action: #selector(PhotonActionSheet.dismiss(_:)))
tapRecognizer.numberOfTapsRequired = 1
tapRecognizer.cancelsTouchesInView = false
tapRecognizer.delegate = self
return tapRecognizer
}()
lazy var cancelButton: UIButton = {
let button = UIButton()
button.setTitle(Strings.CancelButtonTitle, for: .normal)
button.backgroundColor = UIConstants.AppBackgroundColor
button.setTitleColor(UIConstants.SystemBlueColor, for: .normal)
button.layer.cornerRadius = PhotonActionSheetUX.CornerRadius
button.titleLabel?.font = DynamicFontHelper.defaultHelper.DeviceFontExtraLargeBold
button.addTarget(self, action: #selector(PhotonActionSheet.dismiss(_:)), for: .touchUpInside)
button.accessibilityIdentifier = "PhotonMenu.cancel"
return button
}()
init(site: Site, actions: [PhotonActionSheetItem]) {
self.site = site
self.actions = [actions]
self.style = .centered
super.init(nibName: nil, bundle: nil)
}
init(actions: [[PhotonActionSheetItem]]) {
self.actions = actions
self.style = .bottom
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var photonTransitionDelegate: UIViewControllerTransitioningDelegate? {
didSet {
self.transitioningDelegate = photonTransitionDelegate
}
}
override func viewDidLoad() {
super.viewDidLoad()
if style == .centered {
applyBackgroundBlur()
self.tintColor = UIConstants.SystemBlueColor
}
view.addGestureRecognizer(tapRecognizer)
view.addSubview(tableView)
view.accessibilityIdentifier = "Action Sheet"
tableView.bounces = false
tableView.delegate = self
tableView.dataSource = self
tableView.sectionFooterHeight = 0
tableView.keyboardDismissMode = UIScrollViewKeyboardDismissMode.onDrag
tableView.register(PhotonActionSheetCell.self, forCellReuseIdentifier: PhotonActionSheetUX.CellName)
tableView.register(PhotonActionSheetHeaderView.self, forHeaderFooterViewReuseIdentifier: PhotonActionSheetUX.HeaderName)
tableView.register(PhotonActionSheetSeparator.self, forHeaderFooterViewReuseIdentifier: "SeparatorSectionHeader")
tableView.isScrollEnabled = true
tableView.showsVerticalScrollIndicator = false
tableView.layer.cornerRadius = PhotonActionSheetUX.CornerRadius
tableView.separatorStyle = .none
tableView.cellLayoutMarginsFollowReadableWidth = false
tableView.accessibilityIdentifier = "Context Menu"
let footer = UIView(frame: CGRect(origin: CGPoint.zero, size: CGSize(width: tableView.frame.width, height: PhotonActionSheetUX.TablePadding)))
tableView.tableFooterView = footer
tableView.tableHeaderView = footer.clone()
// In a popover the popover provides the blur background
// Not using a background color allows the view to style correctly with the popover arrow
if self.popoverPresentationController == nil {
tableView.backgroundColor = UIConstants.AppBackgroundColor.withAlphaComponent(0.7)
let blurEffect = UIBlurEffect(style: .light)
let blurEffectView = UIVisualEffectView(effect: blurEffect)
tableView.backgroundView = blurEffectView
} else {
tableView.backgroundColor = .clear
}
let width = min(self.view.frame.size.width, PhotonActionSheetUX.MaxWidth) - (PhotonActionSheetUX.Padding * 2)
let height = actionSheetHeight()
if self.modalPresentationStyle == .popover {
self.preferredContentSize = CGSize(width: width, height: height)
}
if self.showCancelButton {
self.view.addSubview(cancelButton)
cancelButton.snp.makeConstraints { make in
make.centerX.equalTo(self.view.snp.centerX)
make.width.equalTo(width)
make.height.equalTo(PhotonActionSheetUX.CancelButtonHeight)
if #available(iOS 11, *) {
let bottomPad: CGFloat
if let window = UIApplication.shared.keyWindow, window.safeAreaInsets.bottom != 0 {
// for iPhone X and similar
bottomPad = 0
} else {
bottomPad = PhotonActionSheetUX.Padding
}
make.bottom.equalTo(self.view.safeAreaLayoutGuide.snp.bottom).offset(-bottomPad)
} else {
make.bottom.equalTo(self.view.snp.bottom).offset(-PhotonActionSheetUX.Padding)
}
}
}
if style == .bottom && self.modalPresentationStyle == .popover {
// We are showing the menu in a popOver
self.actions = actions.map({ $0.reversed() }).reversed()
tableView.frame = CGRect(origin: CGPoint.zero, size: self.preferredContentSize)
return
}
tableView.snp.makeConstraints { make in
make.centerX.equalTo(self.view.snp.centerX)
switch style {
case .bottom:
make.bottom.equalTo(cancelButton.snp.top).offset(-PhotonActionSheetUX.Padding)
case .centered:
make.centerY.equalTo(self.view.snp.centerY)
}
make.width.equalTo(width)
make.height.equalTo(min(height, view.bounds.height - PhotonActionSheetUX.CancelButtonHeight - (PhotonActionSheetUX.Padding * 6)))
}
}
private func applyBackgroundBlur() {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
if let screenshot = appDelegate.window?.screenshot() {
let blurredImage = screenshot.applyBlur(withRadius: 5,
blurType: BOXFILTER,
tintColor: UIColor.black.withAlphaComponent(0.2),
saturationDeltaFactor: 1.8,
maskImage: nil)
let imageView = UIImageView(image: blurredImage)
view.addSubview(imageView)
}
}
fileprivate func actionSheetHeight() -> CGFloat {
let count = actions.reduce(0) { $1.count + $0 }
let headerHeight = (style == .centered) ? PhotonActionSheetUX.HeaderHeight : 0
let separatorHeight = actions.count > 1 ? (actions.count - 1) * Int(PhotonActionSheetUX.SectionVerticalPadding) : 0
return CGFloat(separatorHeight) + headerHeight + ( PhotonActionSheetUX.TablePadding * 2) + CGFloat(count) * PhotonActionSheetUX.RowHeight
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
func dismiss(_ gestureRecognizer: UIGestureRecognizer?) {
self.dismiss(animated: true, completion: nil)
}
deinit {
tableView.dataSource = nil
tableView.delegate = nil
}
override func updateViewConstraints() {
if !self.showCancelButton {
tableView.frame = CGRect(origin: CGPoint.zero, size: self.preferredContentSize)
}
super.updateViewConstraints()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if self.traitCollection.verticalSizeClass != previousTraitCollection?.verticalSizeClass
|| self.traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass {
updateViewConstraints()
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
if tableView.frame.contains(touch.location(in: self.view)) {
return false
}
return true
}
func numberOfSections(in tableView: UITableView) -> Int {
return actions.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return actions[section].count
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let action = actions[indexPath.section][indexPath.row]
guard let handler = action.handler else {
self.dismiss(nil)
return
}
self.dismiss(nil)
return handler(action)
}
func tableView(_ tableView: UITableView, hasFullWidthSeparatorForRowAtIndexPath indexPath: IndexPath) -> Bool {
return false
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return PhotonActionSheetUX.RowHeight
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
// If we have multiple sections show a separator for each one except the first.
if section > 0 {
return PhotonActionSheetUX.SectionVerticalPadding
}
return self.site != nil ? PhotonActionSheetUX.HeaderHeight : 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: PhotonActionSheetUX.CellName, for: indexPath) as! PhotonActionSheetCell
let action = actions[indexPath.section][indexPath.row]
cell.tintColor = action.isEnabled ? UIConstants.SystemBlueColor : self.tintColor
cell.configureCell(action.title, imageString: action.iconString)
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
// If we have multiple sections show a separator for each one except the first.
if section > 0 {
return tableView.dequeueReusableHeaderFooterView(withIdentifier: "SeparatorSectionHeader")
}
guard let site = site else {
return nil
}
let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: PhotonActionSheetUX.HeaderName) as! PhotonActionSheetHeaderView
header.tintColor = self.tintColor
header.configureWithSite(site)
return header
}
}
private class PhotonActionSheetHeaderView: UITableViewHeaderFooterView {
static let Padding: CGFloat = 12
static let VerticalPadding: CGFloat = 2
lazy var titleLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.font = DynamicFontHelper.defaultHelper.MediumSizeBoldFontAS
titleLabel.textAlignment = .left
titleLabel.numberOfLines = 2
return titleLabel
}()
lazy var descriptionLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.font = DynamicFontHelper.defaultHelper.MediumSizeRegularWeightAS
titleLabel.textAlignment = .left
titleLabel.numberOfLines = 1
return titleLabel
}()
lazy var siteImageView: UIImageView = {
let siteImageView = UIImageView()
siteImageView.contentMode = UIViewContentMode.center
siteImageView.clipsToBounds = true
siteImageView.layer.cornerRadius = PhotonActionSheetUX.CornerRadius
siteImageView.layer.borderColor = PhotonActionSheetUX.BorderColor.cgColor
siteImageView.layer.borderWidth = PhotonActionSheetUX.BorderWidth
return siteImageView
}()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
self.backgroundView = UIView()
self.backgroundView?.backgroundColor = .clear
contentView.addSubview(siteImageView)
siteImageView.snp.remakeConstraints { make in
make.centerY.equalTo(contentView)
make.leading.equalTo(contentView).offset(PhotonActionSheetHeaderView.Padding)
make.size.equalTo(PhotonActionSheetUX.SiteImageViewSize)
}
let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel])
stackView.spacing = PhotonActionSheetHeaderView.VerticalPadding
stackView.alignment = .leading
stackView.axis = .vertical
contentView.addSubview(stackView)
stackView.snp.makeConstraints { make in
make.leading.equalTo(siteImageView.snp.trailing).offset(PhotonActionSheetHeaderView.Padding)
make.trailing.equalTo(contentView).inset(PhotonActionSheetHeaderView.Padding)
make.centerY.equalTo(siteImageView.snp.centerY)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
self.siteImageView.image = nil
self.siteImageView.backgroundColor = UIColor.clear
}
func configureWithSite(_ site: Site) {
self.siteImageView.setFavicon(forSite: site) { (color, url) in
self.siteImageView.backgroundColor = color
self.siteImageView.image = self.siteImageView.image?.createScaled(PhotonActionSheetUX.IconSize)
}
self.titleLabel.text = site.title.characters.count <= 1 ? site.url : site.title
self.descriptionLabel.text = site.tileURL.baseDomain
}
}
private struct PhotonActionSheetCellUX {
static let LabelColor = UIConstants.SystemBlueColor
static let BorderWidth: CGFloat = CGFloat(0.5)
static let CellSideOffset = 20
static let TitleLabelOffset = 10
static let CellTopBottomOffset = 12
static let StatusIconSize = 24
static let SelectedOverlayColor = UIColor(white: 0.0, alpha: 0.25)
static let CornerRadius: CGFloat = 3
}
private class PhotonActionSheetSeparator: UITableViewHeaderFooterView {
let separatorLineView = UIView()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
self.backgroundView = UIView()
self.backgroundView?.backgroundColor = .clear
separatorLineView.backgroundColor = UIColor.lightGray
self.contentView.addSubview(separatorLineView)
separatorLineView.snp.makeConstraints { make in
make.leading.trailing.equalTo(self)
make.centerY.equalTo(self)
make.height.equalTo(0.5)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private class PhotonActionSheetCell: UITableViewCell {
lazy var titleLabel: UILabel = {
let titleLabel = UILabel()
titleLabel.font = DynamicFontHelper.defaultHelper.LargeSizeRegularWeightAS
titleLabel.minimumScaleFactor = 0.75 // Scale the font if we run out of space
titleLabel.textColor = PhotonActionSheetCellUX.LabelColor
titleLabel.textAlignment = .left
titleLabel.numberOfLines = 1
titleLabel.adjustsFontSizeToFitWidth = true
return titleLabel
}()
lazy var statusIcon: UIImageView = {
let siteImageView = UIImageView()
siteImageView.contentMode = UIViewContentMode.scaleAspectFit
siteImageView.clipsToBounds = true
siteImageView.layer.cornerRadius = PhotonActionSheetCellUX.CornerRadius
return siteImageView
}()
lazy var selectedOverlay: UIView = {
let selectedOverlay = UIView()
selectedOverlay.backgroundColor = PhotonActionSheetCellUX.SelectedOverlayColor
selectedOverlay.isHidden = true
return selectedOverlay
}()
override var isSelected: Bool {
didSet {
self.selectedOverlay.isHidden = !isSelected
}
}
override func prepareForReuse() {
self.statusIcon.image = nil
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
isAccessibilityElement = true
contentView.addSubview(selectedOverlay)
contentView.addSubview(titleLabel)
contentView.addSubview(statusIcon)
backgroundColor = .clear
selectedOverlay.snp.makeConstraints { make in
make.edges.equalTo(contentView)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(statusIcon.snp.trailing).offset(16)
make.trailing.equalTo(contentView)
make.centerY.equalTo(contentView)
}
statusIcon.snp.makeConstraints { make in
make.size.equalTo(PhotonActionSheetCellUX.StatusIconSize)
make.leading.equalTo(contentView).offset(16)
make.centerY.equalTo(contentView)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configureCell(_ label: String, imageString: String) {
titleLabel.text = label
titleLabel.textColor = self.tintColor
accessibilityIdentifier = imageString
accessibilityLabel = label
if let image = UIImage(named: imageString)?.withRenderingMode(.alwaysTemplate) {
statusIcon.image = image
statusIcon.tintColor = self.tintColor
}
}
}

View file

@ -0,0 +1,79 @@
/* 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 UIKit
class PhotonActionSheetAnimator: NSObject, UIViewControllerAnimatedTransitioning {
var presenting: Bool = false
let animationDuration = 0.4
lazy var shadow: UIView = {
let shadow = UIView()
shadow.backgroundColor = UIColor(white: 0, alpha: 0.5)
return shadow
}()
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let screens = (from: transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!, to: transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!)
guard let actionSheet = (self.presenting ? screens.to : screens.from) as? PhotonActionSheet else {
return
}
let bottomViewController = (self.presenting ? screens.from : screens.to) as UIViewController
animateWitVC(actionSheet, presentingVC: bottomViewController, transitionContext: transitionContext)
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return animationDuration
}
}
extension PhotonActionSheetAnimator: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
self.presenting = true
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
self.presenting = false
return self
}
}
extension PhotonActionSheetAnimator {
fileprivate func animateWitVC(_ actionSheet: PhotonActionSheet, presentingVC viewController: UIViewController, transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
if presenting {
shadow.frame = containerView.bounds
containerView.addSubview(shadow)
actionSheet.view.frame = CGRect(origin: CGPoint(x: 0, y: containerView.frame.size.height), size: containerView.frame.size)
self.shadow.alpha = 0
containerView.addSubview(actionSheet.view)
actionSheet.view.layoutIfNeeded()
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.3, options: [], animations: { () -> Void in
self.shadow.alpha = 1
actionSheet.view.frame = containerView.bounds
actionSheet.view.layoutIfNeeded()
}, completion: { (completed) -> Void in
transitionContext.completeTransition(completed)
})
} else {
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 1.2, initialSpringVelocity: 0.0, options: [], animations: { () -> Void in
self.shadow.alpha = 0
actionSheet.view.frame = CGRect(origin: CGPoint(x: 0, y: containerView.frame.size.height), size: containerView.frame.size)
actionSheet.view.layoutIfNeeded()
}, completion: { (completed) -> Void in
actionSheet.view.removeFromSuperview()
self.shadow.removeFromSuperview()
transitionContext.completeTransition(completed)
})
}
}
}

View file

@ -0,0 +1,232 @@
/* 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 Shared
import Storage
protocol PhotonActionSheetProtocol {
var tabManager: TabManager { get }
var profile: Profile { get }
}
private let log = Logger.browserLogger
extension PhotonActionSheetProtocol {
typealias PresentableVC = UIViewController & UIPopoverPresentationControllerDelegate
typealias MenuAction = () -> Void
typealias IsPrivateTab = Bool
typealias URLOpenAction = (URL?, IsPrivateTab) -> Void
func presentSheetWith(actions: [[PhotonActionSheetItem]], on viewController: PresentableVC, from view: UIView, supressPopover: Bool = false) {
let sheet = PhotonActionSheet(actions: actions)
sheet.modalPresentationStyle = (UIDevice.current.userInterfaceIdiom == .pad && !supressPopover) ? .popover : .overCurrentContext
sheet.photonTransitionDelegate = PhotonActionSheetAnimator()
if let popoverVC = sheet.popoverPresentationController, sheet.modalPresentationStyle == .popover {
popoverVC.delegate = viewController
popoverVC.sourceView = view
popoverVC.sourceRect = CGRect(x: view.frame.width/2, y: view.frame.size.height * 0.75, width: 1, height: 1)
popoverVC.permittedArrowDirections = UIPopoverArrowDirection.up
popoverVC.backgroundColor = UIConstants.AppBackgroundColor.withAlphaComponent(0.7)
}
viewController.present(sheet, animated: true, completion: nil)
}
//Returns a list of actions which is used to build a menu
//OpenURL is a closure that can open a given URL in some view controller. It is up to the class using the menu to know how to open it
func getHomePanelActions() -> [PhotonActionSheetItem] {
guard let tab = self.tabManager.selectedTab else { return [] }
let openTopSites = PhotonActionSheetItem(title: Strings.AppMenuTopSitesTitleString, iconString: "menu-panel-TopSites") { action in
tab.loadRequest(PrivilegedRequest(url: HomePanelType.topSites.localhostURL) as URLRequest)
}
let openBookmarks = PhotonActionSheetItem(title: Strings.AppMenuBookmarksTitleString, iconString: "menu-panel-Bookmarks") { action in
tab.loadRequest(PrivilegedRequest(url: HomePanelType.bookmarks.localhostURL) as URLRequest)
UnifiedTelemetry.recordEvent(category: .action, method: .view, object: .bookmarksPanel, value: .appMenu)
}
let openHistory = PhotonActionSheetItem(title: Strings.AppMenuHistoryTitleString, iconString: "menu-panel-History") { action in
tab.loadRequest(PrivilegedRequest(url: HomePanelType.history.localhostURL) as URLRequest)
}
let openReadingList = PhotonActionSheetItem(title: Strings.AppMenuReadingListTitleString, iconString: "menu-panel-ReadingList") { action in
tab.loadRequest(PrivilegedRequest(url: HomePanelType.readingList.localhostURL) as URLRequest)
}
let openHomePage = PhotonActionSheetItem(title: Strings.AppMenuOpenHomePageTitleString, iconString: "menu-Home") { _ in
HomePageHelper(prefs: self.profile.prefs).openHomePage(tab)
}
var actions = [openTopSites, openBookmarks, openReadingList, openHistory]
if HomePageHelper(prefs: self.profile.prefs).isHomePageAvailable {
actions.insert(openHomePage, at: 0)
}
return actions
}
/*
Returns a list of actions which is used to build the general browser menu
These items repersent global options that are presented in the menu
TODO: These icons should all have the icons and use Strings.swift
*/
typealias PageOptionsVC = QRCodeViewControllerDelegate & SettingsDelegate & PresentingModalViewControllerDelegate & UIViewController
func getOtherPanelActions(vcDelegate: PageOptionsVC) -> [PhotonActionSheetItem] {
var noImageMode: PhotonActionSheetItem? = nil
if #available(iOS 11, *) {
let noImageEnabled = NoImageModeHelper.isActivated(profile.prefs)
let noImageText = noImageEnabled ? Strings.AppMenuNoImageModeDisable : Strings.AppMenuNoImageModeEnable
noImageMode = PhotonActionSheetItem(title: noImageText, iconString: "menu-NoImageMode", isEnabled: noImageEnabled) { action in
NoImageModeHelper.toggle(profile: self.profile, tabManager: self.tabManager)
}
}
let nightModeEnabled = NightModeHelper.isActivated(profile.prefs)
let nightModeText = nightModeEnabled ? Strings.AppMenuNightModeDisable : Strings.AppMenuNightModeEnable
let nightMode = PhotonActionSheetItem(title: nightModeText, iconString: "menu-NightMode", isEnabled: nightModeEnabled) { action in
NightModeHelper.toggle(self.profile.prefs, tabManager: self.tabManager)
}
let openSettings = PhotonActionSheetItem(title: Strings.AppMenuSettingsTitleString, iconString: "menu-Settings") { action in
let settingsTableViewController = AppSettingsTableViewController()
settingsTableViewController.profile = self.profile
settingsTableViewController.tabManager = self.tabManager
settingsTableViewController.settingsDelegate = vcDelegate
let controller = SettingsNavigationController(rootViewController: settingsTableViewController)
controller.popoverDelegate = vcDelegate
controller.modalPresentationStyle = UIModalPresentationStyle.formSheet
vcDelegate.present(controller, animated: true, completion: nil)
}
if let noImageMode = noImageMode {
return [noImageMode, nightMode, openSettings]
}
return [nightMode, openSettings]
}
func getTabActions(tab: Tab, buttonView: UIView,
presentShareMenu: @escaping (URL, Tab, UIView, UIPopoverArrowDirection) -> Void,
findInPage: @escaping () -> Void,
presentableVC: PresentableVC,
success: @escaping (String) -> Void) -> Array<[PhotonActionSheetItem]> {
let toggleActionTitle = tab.desktopSite ? Strings.AppMenuViewMobileSiteTitleString : Strings.AppMenuViewDesktopSiteTitleString
let toggleDesktopSite = PhotonActionSheetItem(title: toggleActionTitle, iconString: "menu-RequestDesktopSite") { action in
tab.toggleDesktopSite()
}
let addReadingList = PhotonActionSheetItem(title: Strings.AppMenuAddToReadingListTitleString, iconString: "addToReadingList") { action in
guard let url = tab.url?.displayURL else { return }
self.profile.readingList?.createRecordWithURL(url.absoluteString, title: tab.title ?? "", addedBy: UIDevice.current.name)
UnifiedTelemetry.recordEvent(category: .action, method: .add, object: .readingListItem, value: .pageActionMenu)
success(Strings.AppMenuAddToReadingListConfirmMessage)
}
let findInPageAction = PhotonActionSheetItem(title: Strings.AppMenuFindInPageTitleString, iconString: "menu-FindInPage") { action in
findInPage()
}
let bookmarkPage = PhotonActionSheetItem(title: Strings.AppMenuAddBookmarkTitleString, iconString: "menu-Bookmark") { action in
//TODO: can all this logic go somewhere else?
guard let url = tab.canonicalURL?.displayURL else { return }
let absoluteString = url.absoluteString
let shareItem = ShareItem(url: absoluteString, title: tab.title, favicon: tab.displayFavicon)
_ = self.profile.bookmarks.shareItem(shareItem)
var userData = [QuickActions.TabURLKey: shareItem.url]
if let title = shareItem.title {
userData[QuickActions.TabTitleKey] = title
}
QuickActions.sharedInstance.addDynamicApplicationShortcutItemOfType(.openLastBookmark,
withUserData: userData,
toApplication: UIApplication.shared)
tab.isBookmarked = true
UnifiedTelemetry.recordEvent(category: .action, method: .add, object: .bookmark, value: .pageActionMenu)
success(Strings.AppMenuAddBookmarkConfirmMessage)
}
let removeBookmark = PhotonActionSheetItem(title: Strings.AppMenuRemoveBookmarkTitleString, iconString: "menu-Bookmark-Remove") { action in
//TODO: can all this logic go somewhere else?
guard let url = tab.url?.displayURL else { return }
let absoluteString = url.absoluteString
self.profile.bookmarks.modelFactory >>== {
$0.removeByURL(absoluteString).uponQueue(.main) { res in
if res.isSuccess {
tab.isBookmarked = false
UnifiedTelemetry.recordEvent(category: .action, method: .delete, object: .bookmark, value: .pageActionMenu)
success(Strings.AppMenuRemoveBookmarkConfirmMessage)
}
}
}
}
let pinToTopSites = PhotonActionSheetItem(title: Strings.PinTopsiteActionTitle, iconString: "action_pin") { action in
guard let url = tab.url?.displayURL,
let sql = self.profile.history as? SQLiteHistory else { return }
let absoluteString = url.absoluteString
sql.getSitesForURLs([absoluteString]) >>== { result in
guard let siteOp = result.asArray().first, let site = siteOp else {
log.warning("Could not get site for \(absoluteString)")
return
}
_ = self.profile.history.addPinnedTopSite(site).value
}
}
let sendToDevice = PhotonActionSheetItem(title: Strings.SendToDeviceTitle, iconString: "menu-Send-to-Device") { action in
guard let bvc = presentableVC as? PresentableVC & InstructionsViewControllerDelegate & ClientPickerViewControllerDelegate else { return }
if !self.profile.hasAccount() {
let instructionsViewController = InstructionsViewController()
instructionsViewController.delegate = bvc
let navigationController = UINavigationController(rootViewController: instructionsViewController)
navigationController.modalPresentationStyle = .formSheet
bvc.present(navigationController, animated: true, completion: nil)
return
}
let clientPickerViewController = ClientPickerViewController()
clientPickerViewController.clientPickerDelegate = bvc
clientPickerViewController.profile = self.profile
clientPickerViewController.profileNeedsShutdown = false
let navigationController = UINavigationController(rootViewController: clientPickerViewController)
navigationController.modalPresentationStyle = .formSheet
bvc.present(navigationController, animated: true, completion: nil)
}
let share = PhotonActionSheetItem(title: Strings.AppMenuSharePageTitleString, iconString: "action_share") { action in
guard let url = tab.canonicalURL?.displayURL else { return }
presentShareMenu(url, tab, buttonView, .up)
}
let closeTab = PhotonActionSheetItem(title: Strings.CloseTabTitle, iconString: "action_remove") { action in
self.tabManager.removeTab(tab)
}
let copyURL = PhotonActionSheetItem(title: Strings.AppMenuCopyURLTitleString, iconString: "menu-Copy-Link") { _ in
UIPasteboard.general.url = tab.canonicalURL?.displayURL
success(Strings.AppMenuCopyURLConfirmMessage)
}
var topActions: [PhotonActionSheetItem] = []
// Disable bookmarking and reading list if the URL is too long.
if !tab.urlIsTooLong {
topActions.append(tab.isBookmarked ? removeBookmark : bookmarkPage)
if tab.readerModeAvailableOrActive {
topActions.append(addReadingList)
}
}
return [topActions, [copyURL, findInPageAction, toggleDesktopSite, pinToTopSites, sendToDevice, closeTab], [share]]
}
}

View file

@ -0,0 +1,33 @@
/* 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 UIKit
class RoundedToolbar: UIToolbar {
fileprivate var layerBackgroundColor: UIColor?
override var backgroundColor: UIColor? {
get { return layerBackgroundColor }
set {
layerBackgroundColor = newValue
}
}
var cornerRadius: CGSize = CGSize.zero
var cornersToRound: UIRectCorner = [.allCorners]
/**
* The toolbar on the menu requires rounded corners on the top and bottom so we need a custom
* view to do this
*/
override func draw(_ rect: CGRect) {
super.draw(rect)
layer.sublayers?.filter { $0.mask != nil } .forEach { $0.removeFromSuperlayer() }
addRoundedCorners(cornersToRound, cornerRadius: cornerRadius, color: layerBackgroundColor ?? UIColor.white)
}
}

View file

@ -0,0 +1,205 @@
/* 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
private struct SearchInputViewUX {
static let horizontalSpacing: CGFloat = 16
static let titleFont: UIFont = UIFont.systemFont(ofSize: 16)
static let titleColor: UIColor = UIColor.lightGray
static let inputColor: UIColor = UIConstants.HighlightBlue
static let borderColor: UIColor = UIConstants.SeparatorColor
static let borderLineWidth: CGFloat = 0.5
static let closeButtonSize: CGFloat = 36
}
@objc protocol SearchInputViewDelegate: class {
func searchInputView(_ searchView: SearchInputView, didChangeTextTo text: String)
func searchInputViewBeganEditing(_ searchView: SearchInputView)
func searchInputViewFinishedEditing(_ searchView: SearchInputView)
}
class SearchInputView: UIView {
weak var delegate: SearchInputViewDelegate?
var showBottomBorder: Bool = true {
didSet {
bottomBorder.isHidden = !showBottomBorder
}
}
lazy var inputField: UITextField = {
let textField = UITextField()
textField.delegate = self
textField.textColor = SearchInputViewUX.inputColor
textField.tintColor = SearchInputViewUX.inputColor
textField.addTarget(self, action: #selector(SearchInputView.inputTextDidChange(_:)), for: .editingChanged)
textField.accessibilityLabel = NSLocalizedString("Search Input Field", tableName: "LoginManager", comment: "Accessibility label for the search input field in the Logins list")
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
return textField
}()
lazy var titleLabel: UILabel = {
let label = UILabel()
label.text = NSLocalizedString("Search", tableName: "LoginManager", comment: "Title for the search field at the top of the Logins list screen")
label.font = SearchInputViewUX.titleFont
label.textColor = SearchInputViewUX.titleColor
return label
}()
lazy var searchIcon: UIImageView = {
return UIImageView(image: UIImage(named: "quickSearch"))
}()
fileprivate lazy var closeButton: UIButton = {
let button = UIButton()
button.addTarget(self, action: #selector(SearchInputView.tappedClose), for: .touchUpInside)
button.setImage(UIImage(named: "clear"), for: UIControlState())
button.accessibilityLabel = NSLocalizedString("Clear Search", tableName: "LoginManager",
comment: "Accessibility message e.g. spoken by VoiceOver after the user taps the close button in the search field to clear the search and exit search mode")
return button
}()
fileprivate var centerContainer = UIView()
fileprivate lazy var bottomBorder: UIView = {
let border = UIView()
border.backgroundColor = SearchInputViewUX.borderColor
return border
}()
fileprivate lazy var overlay: UIView = {
let view = UIView()
view.backgroundColor = UIColor.white
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(SearchInputView.tappedSearch)))
view.isAccessibilityElement = true
view.accessibilityLabel = NSLocalizedString("Enter Search Mode", tableName: "LoginManager", comment: "Accessibility label for entering search mode for logins")
return view
}()
fileprivate(set) var isEditing = false {
didSet {
if isEditing {
overlay.isHidden = true
inputField.isHidden = false
inputField.accessibilityElementsHidden = false
closeButton.isHidden = false
closeButton.accessibilityElementsHidden = false
} else {
overlay.isHidden = false
inputField.isHidden = true
inputField.accessibilityElementsHidden = true
closeButton.isHidden = true
closeButton.accessibilityElementsHidden = true
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.white
isUserInteractionEnabled = true
addSubview(inputField)
addSubview(closeButton)
centerContainer.addSubview(searchIcon)
centerContainer.addSubview(titleLabel)
overlay.addSubview(centerContainer)
addSubview(overlay)
addSubview(bottomBorder)
setupConstraints()
setEditing(false)
}
fileprivate func setupConstraints() {
centerContainer.snp.makeConstraints { make in
make.center.equalTo(overlay)
}
overlay.snp.makeConstraints { make in
make.edges.equalTo(self)
}
searchIcon.snp.makeConstraints { make in
make.right.equalTo(titleLabel.snp.left).offset(-SearchInputViewUX.horizontalSpacing)
make.centerY.equalTo(centerContainer)
}
titleLabel.snp.makeConstraints { make in
make.center.equalTo(centerContainer)
}
inputField.snp.makeConstraints { make in
make.left.equalTo(self).offset(SearchInputViewUX.horizontalSpacing)
make.centerY.equalTo(self)
make.right.equalTo(closeButton.snp.left).offset(-SearchInputViewUX.horizontalSpacing)
}
closeButton.snp.makeConstraints { make in
make.right.equalTo(self).offset(-SearchInputViewUX.horizontalSpacing)
make.centerY.equalTo(self)
make.size.equalTo(SearchInputViewUX.closeButtonSize)
}
bottomBorder.snp.makeConstraints { make in
make.left.right.bottom.equalTo(self)
make.height.equalTo(SearchInputViewUX.borderLineWidth)
}
}
// didSet callbacks don't trigger when a property is being set in the init() call
// but calling a method that does works fine.
fileprivate func setEditing(_ editing: Bool) {
isEditing = editing
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - Selectors
extension SearchInputView {
func tappedSearch() {
isEditing = true
inputField.becomeFirstResponder()
delegate?.searchInputViewBeganEditing(self)
}
func tappedClose() {
isEditing = false
delegate?.searchInputViewFinishedEditing(self)
inputField.text = nil
inputField.resignFirstResponder()
}
func inputTextDidChange(_ textField: UITextField) {
delegate?.searchInputView(self, didChangeTextTo: textField.text ?? "")
}
}
// MARK: - UITextFieldDelegate
extension SearchInputView: UITextFieldDelegate {
func textFieldDidEndEditing(_ textField: UITextField) {
// If there is no text, go back to showing the title view
if (textField.text?.characters.count ?? 0) == 0 {
isEditing = false
delegate?.searchInputViewFinishedEditing(self)
}
}
}

View file

@ -0,0 +1,29 @@
/* 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 UIKit
class SeparatorTableCell: UITableViewCell {
override var textLabel: UILabel? {
return nil
}
override var detailTextLabel: UILabel? {
return nil
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.selectionStyle = UITableViewCellSelectionStyle.none
self.indentationWidth = 0
self.separatorInset = UIEdgeInsets.zero
self.layoutMargins = UIEdgeInsets.zero
self.backgroundColor = UIConstants.PanelBackgroundColor // So we get a gentle white and grey stripe.
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View file

@ -0,0 +1,160 @@
/* 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 UIKit
import Storage
struct SiteTableViewControllerUX {
static let HeaderHeight = CGFloat(32)
static let RowHeight = CGFloat(44)
static let HeaderBorderColor = UIColor(rgb: 0xCFD5D9).withAlphaComponent(0.8)
static let HeaderTextColor = UIAccessibilityDarkerSystemColorsEnabled() ? UIColor.black : UIColor(rgb: 0x232323)
static let HeaderBackgroundColor = UIColor(rgb: 0xf7f8f7)
static let HeaderFont = UIFont.systemFont(ofSize: 12, weight: UIFontWeightMedium)
static let HeaderTextMargin = CGFloat(16)
}
class SiteTableViewHeader: UITableViewHeaderFooterView {
// I can't get drawRect to play nicely with the glass background. As a fallback
// we just use views for the top and bottom borders.
let topBorder = UIView()
let bottomBorder = UIView()
let titleLabel = UILabel()
override var textLabel: UILabel? {
return titleLabel
}
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
topBorder.backgroundColor = SiteTableViewControllerUX.HeaderBorderColor
bottomBorder.backgroundColor = SiteTableViewControllerUX.HeaderBorderColor
contentView.backgroundColor = SiteTableViewControllerUX.HeaderBackgroundColor
titleLabel.font = DynamicFontHelper.defaultHelper.DeviceFontMediumBold
titleLabel.textColor = SiteTableViewControllerUX.HeaderTextColor
titleLabel.textAlignment = .left
addSubview(topBorder)
addSubview(bottomBorder)
contentView.addSubview(titleLabel)
topBorder.snp.makeConstraints { make in
make.left.right.equalTo(self)
make.top.equalTo(self).offset(-0.5)
make.height.equalTo(0.5)
}
bottomBorder.snp.makeConstraints { make in
make.left.right.bottom.equalTo(self)
make.height.equalTo(0.5)
}
// A table view will initialize the header with CGSizeZero before applying the actual size. Hence, the label's constraints
// must not impose a minimum width on the content view.
titleLabel.snp.makeConstraints { make in
make.left.equalTo(contentView).offset(SiteTableViewControllerUX.HeaderTextMargin).priority(1000)
make.right.equalTo(contentView).offset(-SiteTableViewControllerUX.HeaderTextMargin).priority(1000)
make.left.greaterThanOrEqualTo(contentView) // Fallback for when the left space constraint breaks
make.right.lessThanOrEqualTo(contentView) // Fallback for when the right space constraint breaks
make.centerY.equalTo(contentView)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/**
* Provides base shared functionality for site rows and headers.
*/
class SiteTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
fileprivate let CellIdentifier = "CellIdentifier"
fileprivate let HeaderIdentifier = "HeaderIdentifier"
var profile: Profile! {
didSet {
reloadData()
}
}
var data: Cursor<Site> = Cursor<Site>(status: .success, msg: "No data set")
var tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalTo(self.view)
return
}
tableView.delegate = self
tableView.dataSource = self
tableView.register(SiteTableViewCell.self, forCellReuseIdentifier: CellIdentifier)
tableView.register(SiteTableViewHeader.self, forHeaderFooterViewReuseIdentifier: HeaderIdentifier)
tableView.layoutMargins = UIEdgeInsets.zero
tableView.keyboardDismissMode = UIScrollViewKeyboardDismissMode.onDrag
tableView.backgroundColor = UIConstants.PanelBackgroundColor
tableView.separatorColor = UIConstants.SeparatorColor
tableView.accessibilityIdentifier = "SiteTable"
tableView.cellLayoutMarginsFollowReadableWidth = false
// Set an empty footer to prevent empty cells from appearing in the list.
tableView.tableFooterView = UIView()
}
deinit {
// The view might outlive this view controller thanks to animations;
// explicitly nil out its references to us to avoid crashes. Bug 1218826.
tableView.dataSource = nil
tableView.delegate = nil
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animate(alongsideTransition: { context in
//The AS context menu does not behave correctly. Dismiss it when rotating.
if let _ = self.presentedViewController as? PhotonActionSheet {
self.presentedViewController?.dismiss(animated: true, completion: nil)
}
}, completion: nil)
}
func reloadData() {
if data.status != .success {
print("Err: \(data.statusMessage)", terminator: "\n")
} else {
self.tableView.reloadData()
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifier, for: indexPath)
if self.tableView(tableView, hasFullWidthSeparatorForRowAtIndexPath: indexPath) {
cell.separatorInset = UIEdgeInsets.zero
}
return cell
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
return tableView.dequeueReusableHeaderFooterView(withIdentifier: HeaderIdentifier)
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return SiteTableViewControllerUX.HeaderHeight
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return SiteTableViewControllerUX.RowHeight
}
func tableView(_ tableView: UITableView, hasFullWidthSeparatorForRowAtIndexPath indexPath: IndexPath) -> Bool {
return false
}
}

View file

@ -0,0 +1,319 @@
/* 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)
}
}

View file

@ -0,0 +1,255 @@
/* 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
import XCGLogger
private let log = Logger.browserLogger
struct TabsButtonUX {
static let TitleColor: UIColor = UIColor(rgb: 0x272727)
static let TitleBackgroundColor: UIColor = UIColor.white
static let CornerRadius: CGFloat = 2
static let TitleFont: UIFont = UIConstants.DefaultChromeSmallFontBold
static let BorderStrokeWidth: CGFloat = 3
static let BorderColor: UIColor = UIColor.darkGray
static let TitleInsets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
static let Themes: [String: Theme] = {
var themes = [String: Theme]()
var theme = Theme()
theme.borderColor = UIColor(rgb: 0xD2D2D4)
theme.backgroundColor = UIColor(rgb: 0x38383D)
theme.textColor = UIColor(rgb: 0xD2D2D4)
theme.highlightButtonColor = UIConstants.PrivateModePurple
theme.highlightTextColor = TabsButtonUX.TitleColor
theme.highlightBorderColor = UIConstants.PrivateModePurple
themes[Theme.PrivateMode] = theme
theme = Theme()
theme.borderColor = UIColor(rgb: 0x272727)
theme.backgroundColor = UIConstants.AppBackgroundColor
theme.textColor = UIColor(rgb: 0x272727)
theme.highlightButtonColor = TabsButtonUX.TitleColor
theme.highlightTextColor = TabsButtonUX.TitleBackgroundColor
theme.highlightBorderColor = TabsButtonUX.TitleColor
themes[Theme.NormalMode] = theme
return themes
}()
}
class TabsButton: UIButton {
var textColor = UIColor.white {
didSet {
countLabel.textColor = textColor
borderView.color = textColor
}
}
var titleBackgroundColor = UIColor.white {
didSet {
labelBackground.backgroundColor = titleBackgroundColor
}
}
var highlightTextColor: UIColor?
var highlightBackgroundColor: UIColor?
override var isHighlighted: Bool {
didSet {
if isHighlighted {
countLabel.textColor = textColor
borderView.color = titleBackgroundColor
labelBackground.backgroundColor = titleBackgroundColor
} else {
countLabel.textColor = textColor
borderView.color = textColor
labelBackground.backgroundColor = titleBackgroundColor
}
}
}
override var transform: CGAffineTransform {
didSet {
clonedTabsButton?.transform = transform
}
}
lazy var countLabel: UILabel = {
let label = UILabel()
label.font = TabsButtonUX.TitleFont
label.layer.cornerRadius = TabsButtonUX.CornerRadius
label.textAlignment = NSTextAlignment.center
label.isUserInteractionEnabled = false
return label
}()
lazy var insideButton: UIView = {
let view = UIView()
view.clipsToBounds = false
view.isUserInteractionEnabled = false
return view
}()
fileprivate lazy var labelBackground: UIView = {
let background = UIView()
background.layer.cornerRadius = TabsButtonUX.CornerRadius
background.isUserInteractionEnabled = false
return background
}()
fileprivate lazy var borderView: InnerStrokedView = {
let border = InnerStrokedView()
border.strokeWidth = TabsButtonUX.BorderStrokeWidth
border.cornerRadius = TabsButtonUX.CornerRadius
border.isUserInteractionEnabled = false
return border
}()
// Used to temporarily store the cloned button so we can respond to layout changes during animation
fileprivate weak var clonedTabsButton: TabsButton?
override init(frame: CGRect) {
super.init(frame: frame)
insideButton.addSubview(labelBackground)
insideButton.addSubview(borderView)
insideButton.addSubview(countLabel)
addSubview(insideButton)
isAccessibilityElement = true
accessibilityTraits |= UIAccessibilityTraitButton
}
override func updateConstraints() {
super.updateConstraints()
labelBackground.snp.remakeConstraints { (make) -> Void in
make.edges.equalTo(insideButton)
}
borderView.snp.remakeConstraints { (make) -> Void in
make.edges.equalTo(insideButton)
}
countLabel.snp.remakeConstraints { (make) -> Void in
make.edges.equalTo(insideButton)
}
insideButton.snp.remakeConstraints { (make) -> Void in
make.size.equalTo(24)
make.center.equalTo(self)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func clone() -> UIView {
let button = TabsButton()
button.accessibilityLabel = accessibilityLabel
button.countLabel.text = countLabel.text
// Copy all of the styable properties over to the new TabsButton
button.countLabel.font = countLabel.font
button.countLabel.textColor = countLabel.textColor
button.countLabel.layer.cornerRadius = countLabel.layer.cornerRadius
button.labelBackground.backgroundColor = labelBackground.backgroundColor
button.labelBackground.layer.cornerRadius = labelBackground.layer.cornerRadius
button.borderView.strokeWidth = borderView.strokeWidth
button.borderView.color = borderView.color
button.borderView.cornerRadius = borderView.cornerRadius
return button
}
func updateTabCount(_ count: Int, animated: Bool = true) {
let count = max(count, 1)
let currentCount = self.countLabel.text
let infinity = "\u{221E}"
let countToBe = (count < 100) ? count.description : infinity
// only animate a tab count change if the tab count has actually changed
if currentCount != count.description || (clonedTabsButton?.countLabel.text ?? count.description) != count.description {
if let _ = self.clonedTabsButton {
self.clonedTabsButton?.layer.removeAllAnimations()
self.clonedTabsButton?.removeFromSuperview()
insideButton.layer.removeAllAnimations()
}
// make a 'clone' of the tabs button
let newTabsButton = clone() as! TabsButton
self.clonedTabsButton = newTabsButton
newTabsButton.frame = self.bounds
newTabsButton.addTarget(self, action: #selector(TabsButton.cloneDidClickTabs), for: UIControlEvents.touchUpInside)
newTabsButton.countLabel.text = countToBe
newTabsButton.accessibilityValue = countToBe
newTabsButton.insideButton.frame = self.insideButton.frame
newTabsButton.snp.removeConstraints()
self.addSubview(newTabsButton)
newTabsButton.snp.makeConstraints { make in
make.center.equalTo(self)
}
// Instead of changing the anchorPoint of the CALayer, lets alter the rotation matrix math to be
// a rotation around a non-origin point
let frame = self.insideButton.frame
let halfTitleHeight = frame.height / 2
var newFlipTransform = CATransform3DIdentity
newFlipTransform = CATransform3DTranslate(newFlipTransform, 0, halfTitleHeight, 0)
newFlipTransform.m34 = -1.0 / 200.0 // add some perspective
newFlipTransform = CATransform3DRotate(newFlipTransform, CGFloat(-(Double.pi / 2)), 1.0, 0.0, 0.0)
newTabsButton.insideButton.layer.transform = newFlipTransform
var oldFlipTransform = CATransform3DIdentity
oldFlipTransform = CATransform3DTranslate(oldFlipTransform, 0, halfTitleHeight, 0)
oldFlipTransform.m34 = -1.0 / 200.0 // add some perspective
oldFlipTransform = CATransform3DRotate(oldFlipTransform, CGFloat(-(Double.pi / 2)), 1.0, 0.0, 0.0)
let animate = {
newTabsButton.insideButton.layer.transform = CATransform3DIdentity
self.insideButton.layer.transform = oldFlipTransform
self.insideButton.layer.opacity = 0
}
let completion: (Bool) -> Void = { completed in
let noActiveAnimations = self.insideButton.layer.animationKeys()?.isEmpty ?? true
if completed || noActiveAnimations {
newTabsButton.removeFromSuperview()
self.insideButton.layer.opacity = 1
self.insideButton.layer.transform = CATransform3DIdentity
}
self.accessibilityLabel = NSLocalizedString("Show Tabs", comment: "Accessibility label for the tabs button in the (top) tab toolbar")
self.countLabel.text = countToBe
self.accessibilityValue = countToBe
}
if animated {
UIView.animate(withDuration: 1.5, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.0, options: UIViewAnimationOptions(), animations: animate, completion: completion)
} else {
completion(true)
}
}
}
func cloneDidClickTabs() {
sendActions(for: UIControlEvents.touchUpInside)
}
}
extension TabsButton: Themeable {
func applyTheme(_ themeName: String) {
guard let theme = TabsButtonUX.Themes[themeName] else {
fatalError("Theme not found")
}
titleBackgroundColor = theme.backgroundColor!
textColor = theme.textColor!
countLabel.textColor = textColor
borderView.color = textColor
labelBackground.backgroundColor = titleBackgroundColor
}
}

View file

@ -0,0 +1,23 @@
/* 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
struct Theme {
var URLFontColor: UIColor?
var hostFontColor: UIColor?
var backgroundColor: UIColor?
var textColor: UIColor?
var highlightColor: UIColor?
var tintColor: UIColor?
var buttonTintColor: UIColor?
var activeBorderColor: UIColor?
var borderColor: UIColor?
var highlightButtonColor: UIColor?
var highlightBorderColor: UIColor?
var highlightTextColor: UIColor?
var disabledButtonColor: UIColor?
static let PrivateMode = "Private"
static let NormalMode = "Normal"
}

View file

@ -0,0 +1,126 @@
/* 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 UIKit
private struct UX {
static let TopColor = UIColor(red: 179 / 255, green: 83 / 255, blue: 253 / 255, alpha: 1)
static let BottomColor = UIColor(red: 146 / 255, green: 16 / 255, blue: 253, alpha: 1)
// The amount of pixels the toggle button will expand over the normal size. This results in the larger -> contract animation.
static let ExpandDelta: CGFloat = 5
static let ShowDuration: TimeInterval = 0.4
static let HideDuration: TimeInterval = 0.2
static let BackgroundSize = CGSize(width: 32, height: 32)
}
class ToggleButton: UIButton {
func setSelected(_ selected: Bool, animated: Bool = true) {
self.isSelected = selected
if animated {
animateSelection(selected)
}
}
fileprivate func updateMaskPathForSelectedState(_ selected: Bool) {
let path = CGMutablePath()
if selected {
var rect = CGRect(origin: CGPoint.zero, size: UX.BackgroundSize)
rect.center = maskShapeLayer.position
path.addEllipse(in: rect)
} else {
path.addEllipse(in: CGRect(origin: maskShapeLayer.position, size: CGSize.zero))
}
self.maskShapeLayer.path = path
}
fileprivate func animateSelection(_ selected: Bool) {
var endFrame = CGRect(origin: CGPoint.zero, size: UX.BackgroundSize)
endFrame.center = maskShapeLayer.position
if selected {
let animation = CAKeyframeAnimation(keyPath: "path")
let startPath = CGMutablePath()
startPath.addEllipse(in: CGRect(origin: maskShapeLayer.position, size: CGSize.zero))
let largerPath = CGMutablePath()
let largerBounds = endFrame.insetBy(dx: -UX.ExpandDelta, dy: -UX.ExpandDelta)
largerPath.addEllipse(in: largerBounds)
let endPath = CGMutablePath()
endPath.addEllipse(in: endFrame)
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animation.values = [
startPath,
largerPath,
endPath
]
animation.duration = UX.ShowDuration
self.maskShapeLayer.path = endPath
self.maskShapeLayer.add(animation, forKey: "grow")
} else {
let animation = CABasicAnimation(keyPath: "path")
animation.duration = UX.HideDuration
animation.fillMode = kCAFillModeForwards
let fromPath = CGMutablePath()
fromPath.addEllipse(in: endFrame)
animation.fromValue = fromPath
animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
let toPath = CGMutablePath()
toPath.addEllipse(in: CGRect(origin: self.maskShapeLayer.bounds.center, size: CGSize.zero))
self.maskShapeLayer.path = toPath
self.maskShapeLayer.add(animation, forKey: "shrink")
}
}
lazy fileprivate var backgroundView: UIView = {
let view = UIView()
view.isUserInteractionEnabled = false
view.layer.addSublayer(self.gradientLayer)
return view
}()
lazy fileprivate var maskShapeLayer: CAShapeLayer = {
let circle = CAShapeLayer()
return circle
}()
lazy fileprivate var gradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [UX.TopColor.cgColor, UX.BottomColor.cgColor]
gradientLayer.mask = self.maskShapeLayer
return gradientLayer
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentMode = UIViewContentMode.redraw
insertSubview(backgroundView, belowSubview: imageView!)
}
override func layoutSubviews() {
super.layoutSubviews()
let zeroFrame = CGRect(origin: CGPoint.zero, size: frame.size)
backgroundView.frame = zeroFrame
// Make the gradient larger than normal to allow the mask transition to show when it blows up
// a little larger than the resting size
gradientLayer.bounds = backgroundView.frame.insetBy(dx: -UX.ExpandDelta, dy: -UX.ExpandDelta)
maskShapeLayer.bounds = backgroundView.frame
gradientLayer.position = CGPoint(x: zeroFrame.midX, y: zeroFrame.midY)
maskShapeLayer.position = CGPoint(x: zeroFrame.midX, y: zeroFrame.midY)
updateMaskPathForSelectedState(isSelected)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View file

@ -0,0 +1,93 @@
/* 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
class Toolbar: UIView {
var drawTopBorder = false
var drawBottomBorder = false
var drawSeperators = false
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = UIColor.clear
// Allow the view to redraw itself on rotation changes
contentMode = UIViewContentMode.redraw
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func drawLine(_ context: CGContext, width: CGFloat, start: CGPoint, end: CGPoint) {
context.setStrokeColor(UIConstants.BorderColor.cgColor)
context.setLineWidth(width * (1 / UIScreen.main.scale) )
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) {
if let context = UIGraphicsGetCurrentContext() {
if drawTopBorder {
drawLine(context, width: 1, start: CGPoint(x: 0, y: 0), end: CGPoint(x: frame.width, y: 0))
}
if drawBottomBorder {
drawLine(context, width: 1, start: CGPoint(x: 0, y: frame.height), end: CGPoint(x: frame.width, y: frame.height))
}
if drawSeperators {
var skippedFirst = false
for view in subviews {
if skippedFirst {
let frame = view.frame
drawLine(context,
width: 0.5,
start: CGPoint(x: floor(frame.origin.x), y: 0),
end: CGPoint(x: floor(frame.origin.x), y: self.frame.height))
} else {
skippedFirst = true
}
}
}
}
}
func addButtons(_ buttons: [UIButton]) {
for button in buttons {
button.setTitleColor(UIColor.black, for: UIControlState())
button.setTitleColor(UIColor.gray, for: UIControlState.disabled)
button.imageView?.contentMode = UIViewContentMode.scaleAspectFit
addSubview(button)
}
}
override func updateConstraints() {
var prev: UIView? = nil
for view in self.subviews {
view.snp.remakeConstraints { make in
if let prev = prev {
make.left.equalTo(prev.snp.right)
} else {
make.left.equalTo(self)
}
prev = view
var bottomInset: CGFloat = 0.0
if #available(iOS 11, *) {
if let window = UIApplication.shared.keyWindow {
bottomInset = window.safeAreaInsets.bottom
}
}
make.top.equalTo(self)
make.height.equalTo(UIConstants.BottomToolbarHeight - bottomInset)
make.width.equalTo(self).dividedBy(self.subviews.count)
}
}
super.updateConstraints()
}
}

View file

@ -0,0 +1,263 @@
/* 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 UIKit
struct TwoLineCellUX {
static let ImageSize: CGFloat = 29
static let ImageCornerRadius: CGFloat = 8
static let BorderViewMargin: CGFloat = 16
static let BadgeSize: CGFloat = 16
static let BadgeMargin: CGFloat = 16
static let BorderFrameSize: CGFloat = 32
static let TextColor = UIAccessibilityDarkerSystemColorsEnabled() ? UIColor.black : UIColor(rgb: 0x333333)
static let DetailTextColor = UIAccessibilityDarkerSystemColorsEnabled() ? UIColor.darkGray : UIColor.gray
static let DetailTextTopMargin: CGFloat = 0
}
class TwoLineTableViewCell: UITableViewCell {
fileprivate let twoLineHelper = TwoLineCellHelper()
let _textLabel = UILabel()
let _detailTextLabel = UILabel()
// Override the default labels with our own to disable default UITableViewCell label behaviours like dynamic type
override var textLabel: UILabel? {
return _textLabel
}
override var detailTextLabel: UILabel? {
return _detailTextLabel
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: UITableViewCellStyle.subtitle, reuseIdentifier: reuseIdentifier)
contentView.addSubview(_textLabel)
contentView.addSubview(_detailTextLabel)
twoLineHelper.setUpViews(self, textLabel: textLabel!, detailTextLabel: detailTextLabel!, imageView: imageView!)
indentationWidth = 0
layoutMargins = UIEdgeInsets.zero
separatorInset = UIEdgeInsets(top: 0, left: TwoLineCellUX.ImageSize + 2 * TwoLineCellUX.BorderViewMargin, bottom: 0, right: 0)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
twoLineHelper.layoutSubviews()
}
override func prepareForReuse() {
super.prepareForReuse()
self.textLabel!.alpha = 1
self.imageView!.alpha = 1
self.selectionStyle = .default
separatorInset = UIEdgeInsets(top: 0, left: TwoLineCellUX.ImageSize + 2 * TwoLineCellUX.BorderViewMargin, bottom: 0, right: 0)
twoLineHelper.setupDynamicFonts()
}
// Save background color on UITableViewCell "select" because it disappears in the default behavior
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
let color = imageView?.backgroundColor
super.setHighlighted(highlighted, animated: animated)
imageView?.backgroundColor = color
}
// Save background color on UITableViewCell "select" because it disappears in the default behavior
override func setSelected(_ selected: Bool, animated: Bool) {
let color = imageView?.backgroundColor
super.setSelected(selected, animated: animated)
imageView?.backgroundColor = color
}
func setRightBadge(_ badge: UIImage?) {
if let badge = badge {
self.accessoryView = UIImageView(image: badge)
} else {
self.accessoryView = nil
}
twoLineHelper.hasRightBadge = badge != nil
}
func setLines(_ text: String?, detailText: String?) {
twoLineHelper.setLines(text, detailText: detailText)
}
func mergeAccessibilityLabels(_ views: [AnyObject?]? = nil) {
twoLineHelper.mergeAccessibilityLabels(views)
}
}
class SiteTableViewCell: TwoLineTableViewCell {
let borderView = UIView()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: UITableViewCellStyle.subtitle, reuseIdentifier: reuseIdentifier)
twoLineHelper.setUpViews(self, textLabel: textLabel!, detailTextLabel: detailTextLabel!, imageView: imageView!)
}
override func layoutSubviews() {
super.layoutSubviews()
twoLineHelper.layoutSubviews()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class TwoLineHeaderFooterView: UITableViewHeaderFooterView {
fileprivate let twoLineHelper = TwoLineCellHelper()
// UITableViewHeaderFooterView includes textLabel and detailTextLabel, so we can't override
// them. Unfortunately, they're also used in ways that interfere with us just using them: I get
// hard crashes in layout if I just use them; it seems there's a battle over adding to the
// contentView. So we add our own members, and cover up the other ones.
let _textLabel = UILabel()
let _detailTextLabel = UILabel()
let imageView = UIImageView()
// Yes, this is strange.
override var textLabel: UILabel? {
return _textLabel
}
// Yes, this is strange.
override var detailTextLabel: UILabel? {
return _detailTextLabel
}
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
twoLineHelper.setUpViews(self, textLabel: _textLabel, detailTextLabel: _detailTextLabel, imageView: imageView)
contentView.addSubview(_textLabel)
contentView.addSubview(_detailTextLabel)
contentView.addSubview(imageView)
layoutMargins = UIEdgeInsets.zero
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
twoLineHelper.layoutSubviews()
}
override func prepareForReuse() {
super.prepareForReuse()
twoLineHelper.setupDynamicFonts()
}
func mergeAccessibilityLabels(_ views: [AnyObject?]? = nil) {
twoLineHelper.mergeAccessibilityLabels(views)
}
}
private class TwoLineCellHelper {
weak var container: UIView?
var textLabel: UILabel!
var detailTextLabel: UILabel!
var imageView: UIImageView!
var hasRightBadge: Bool = false
// TODO: Not ideal. We should figure out a better way to get this initialized.
func setUpViews(_ container: UIView, textLabel: UILabel, detailTextLabel: UILabel, imageView: UIImageView) {
self.container = container
self.textLabel = textLabel
self.detailTextLabel = detailTextLabel
self.imageView = imageView
if let headerView = self.container as? UITableViewHeaderFooterView {
headerView.contentView.backgroundColor = UIColor.clear
} else {
self.container?.backgroundColor = UIColor.clear
}
textLabel.textColor = TwoLineCellUX.TextColor
detailTextLabel.textColor = TwoLineCellUX.DetailTextColor
setupDynamicFonts()
imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = 6 //hmm
imageView.layer.masksToBounds = true
}
func setupDynamicFonts() {
textLabel.font = DynamicFontHelper.defaultHelper.DeviceFontHistoryPanel
detailTextLabel.font = DynamicFontHelper.defaultHelper.SmallSizeRegularWeightAS
}
func layoutSubviews() {
guard let container = self.container else {
return
}
let height = container.frame.height
let textLeft = TwoLineCellUX.ImageSize + 2 * TwoLineCellUX.BorderViewMargin
let textLabelHeight = textLabel.intrinsicContentSize.height
let detailTextLabelHeight = detailTextLabel.intrinsicContentSize.height
var contentHeight = textLabelHeight
if detailTextLabelHeight > 0 {
contentHeight += detailTextLabelHeight + TwoLineCellUX.DetailTextTopMargin
}
let textRightInset: CGFloat = hasRightBadge ? (TwoLineCellUX.BadgeSize + TwoLineCellUX.BadgeMargin) : 0
imageView.frame = CGRect(x: TwoLineCellUX.BorderViewMargin, y: (height - TwoLineCellUX.ImageSize) / 2, width: TwoLineCellUX.ImageSize, height: TwoLineCellUX.ImageSize)
textLabel.frame = CGRect(x: textLeft, y: (height - contentHeight) / 2,
width: container.frame.width - textLeft - TwoLineCellUX.BorderViewMargin - textRightInset, height: textLabelHeight)
detailTextLabel.frame = CGRect(x: textLeft, y: textLabel.frame.maxY + TwoLineCellUX.DetailTextTopMargin,
width: container.frame.width - textLeft - TwoLineCellUX.BorderViewMargin - textRightInset, height: detailTextLabelHeight)
}
func setLines(_ text: String?, detailText: String?) {
if text?.isEmpty ?? true {
textLabel.text = detailText
detailTextLabel.text = nil
} else {
textLabel.text = text
detailTextLabel.text = detailText
}
}
func mergeAccessibilityLabels(_ labels: [AnyObject?]?) {
let labels = labels ?? [textLabel, imageView, detailTextLabel]
let label = labels.map({ (label: AnyObject?) -> NSAttributedString? in
var label = label
if let view = label as? UIView {
label = view.value(forKey: "accessibilityLabel") as (AnyObject?)
}
if let attrString = label as? NSAttributedString {
return attrString
} else if let string = label as? String {
return NSAttributedString(string: string)
} else {
return nil
}
}).filter({
$0 != nil
}).reduce(NSMutableAttributedString(string: ""), {
if $0.length > 0 {
$0.append(NSAttributedString(string: ", "))
}
$0.append($1!)
return $0
})
container?.isAccessibilityElement = true
container?.setValue(NSAttributedString(attributedString: label), forKey: "accessibilityLabel")
}
}