mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-09 09:18:42 +09:00
Dactyloidae iOS initial commit
This commit is contained in:
parent
daa6179d22
commit
7154a0497e
2123 changed files with 197052 additions and 0 deletions
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
280
mobile/ios/Client/Frontend/Widgets/AutocompleteTextField.swift
Normal file
280
mobile/ios/Client/Frontend/Widgets/AutocompleteTextField.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
123
mobile/ios/Client/Frontend/Widgets/ChevronView.swift
Normal file
123
mobile/ios/Client/Frontend/Widgets/ChevronView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
55
mobile/ios/Client/Frontend/Widgets/ErrorToast.swift
Normal file
55
mobile/ios/Client/Frontend/Widgets/ErrorToast.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
187
mobile/ios/Client/Frontend/Widgets/GradientProgressBar.swift
Normal file
187
mobile/ios/Client/Frontend/Widgets/GradientProgressBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
70
mobile/ios/Client/Frontend/Widgets/HistoryBackButton.swift
Normal file
70
mobile/ios/Client/Frontend/Widgets/HistoryBackButton.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
49
mobile/ios/Client/Frontend/Widgets/InnerStrokedView.swift
Normal file
49
mobile/ios/Client/Frontend/Widgets/InnerStrokedView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
14
mobile/ios/Client/Frontend/Widgets/InsetButton.swift
Normal file
14
mobile/ios/Client/Frontend/Widgets/InsetButton.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
387
mobile/ios/Client/Frontend/Widgets/LoginTableViewCell.swift
Normal file
387
mobile/ios/Client/Frontend/Widgets/LoginTableViewCell.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
496
mobile/ios/Client/Frontend/Widgets/PhotonActionSheet.swift
Normal file
496
mobile/ios/Client/Frontend/Widgets/PhotonActionSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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]]
|
||||
}
|
||||
}
|
||||
|
||||
33
mobile/ios/Client/Frontend/Widgets/RoundedToolbar.swift
Normal file
33
mobile/ios/Client/Frontend/Widgets/RoundedToolbar.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
205
mobile/ios/Client/Frontend/Widgets/SearchInputView.swift
Normal file
205
mobile/ios/Client/Frontend/Widgets/SearchInputView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
mobile/ios/Client/Frontend/Widgets/SeparatorTableCell.swift
Normal file
29
mobile/ios/Client/Frontend/Widgets/SeparatorTableCell.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
160
mobile/ios/Client/Frontend/Widgets/SiteTableViewController.swift
Normal file
160
mobile/ios/Client/Frontend/Widgets/SiteTableViewController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
319
mobile/ios/Client/Frontend/Widgets/SnackBar.swift
Normal file
319
mobile/ios/Client/Frontend/Widgets/SnackBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
255
mobile/ios/Client/Frontend/Widgets/TabsButton.swift
Normal file
255
mobile/ios/Client/Frontend/Widgets/TabsButton.swift
Normal 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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
23
mobile/ios/Client/Frontend/Widgets/Theme.swift
Normal file
23
mobile/ios/Client/Frontend/Widgets/Theme.swift
Normal 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"
|
||||
}
|
||||
126
mobile/ios/Client/Frontend/Widgets/ToggleButton.swift
Normal file
126
mobile/ios/Client/Frontend/Widgets/ToggleButton.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
93
mobile/ios/Client/Frontend/Widgets/Toolbar.swift
Normal file
93
mobile/ios/Client/Frontend/Widgets/Toolbar.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
263
mobile/ios/Client/Frontend/Widgets/TwoLineCell.swift
Normal file
263
mobile/ios/Client/Frontend/Widgets/TwoLineCell.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue