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

496 lines
20 KiB
Swift

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import 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
}
}
}