Tusker/Tusker/Views/Attachments/AttachmentsContainerView.swift

475 lines
20 KiB
Swift

//
// AttachmentsContainerView.swift
// Tusker
//
// Created by Shadowfacts on 6/16/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class AttachmentsContainerView: UIView {
weak var delegate: AttachmentViewDelegate?
private var attachmentTokens: [AttachmentToken] = []
var attachments: [Attachment]!
let attachmentViews: NSHashTable<AttachmentView> = .weakObjects()
let attachmentStacks: NSHashTable<UIStackView> = .weakObjects()
var moreView: UIView?
private var aspectRatioConstraint: NSLayoutConstraint?
private(set) var aspectRatio: CGFloat = 16/9 {
didSet {
if aspectRatio != aspectRatioConstraint?.multiplier {
aspectRatioConstraint?.isActive = false
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: aspectRatio)
aspectRatioConstraint!.isActive = true
}
}
}
var blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView?
var contentHidden: Bool! {
didSet {
guard let blurView = blurView,
let hideButtonView = hideButtonView else { return }
blurView.alpha = self.contentHidden ? 1 : 0
hideButtonView.alpha = self.contentHidden ? 0 : 1
}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
self.isUserInteractionEnabled = true
self.layer.cornerRadius = 5
self.layer.masksToBounds = true
createBlurView()
createHideButton()
}
func getAttachmentView(for attachment: Attachment) -> AttachmentView? {
return attachmentViews.allObjects.first { $0.attachment.id == attachment.id }
}
// MARK: - User Interaface
func updateUI(attachments: [Attachment]) {
let newTokens = attachments.map { AttachmentToken(attachment: $0) }
guard self.attachmentTokens != newTokens else {
return
}
self.attachments = attachments
self.attachmentTokens = newTokens
attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview()
var accessibilityElements = [Any]()
if attachments.count > 0 {
self.isHidden = false
var aspectRatio: CGFloat = 16/9
switch attachments.count {
case 1:
let attachmentView = createAttachmentView(index: 0, hSize: .full, vSize: .full)
attachmentView.layer.cornerRadius = 5
attachmentView.layer.cornerCurve = .continuous
attachmentView.layer.masksToBounds = true
fillView(attachmentView)
sendSubviewToBack(attachmentView)
accessibilityElements.append(attachmentView)
if Preferences.shared.showUncroppedMediaInline,
let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
aspectRatio = attachmentAspectRatio
}
case 2:
let left = createAttachmentView(index: 0, hSize: .half, vSize: .full)
left.layer.cornerRadius = 5
left.layer.cornerCurve = .continuous
left.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
left.layer.masksToBounds = true
let right = createAttachmentView(index: 1, hSize: .half, vSize: .full)
right.layer.cornerRadius = 5
right.layer.cornerCurve = .continuous
right.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
right.layer.masksToBounds = true
let stack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
right
])
attachmentStacks.add(stack)
fillView(stack)
sendSubviewToBack(stack)
NSLayoutConstraint.activate([
left.halfWidth()
])
accessibilityElements.append(left)
accessibilityElements.append(right)
case 3:
let left = createAttachmentView(index: 0, hSize: .half, vSize: .full)
left.layer.cornerRadius = 5
left.layer.cornerCurve = .continuous
left.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
left.layer.masksToBounds = true
let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half)
topRight.layer.cornerRadius = 5
topRight.layer.cornerCurve = .continuous
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let bottomRight = createAttachmentView(index: 2, hSize: .half, vSize: .half)
bottomRight.layer.cornerRadius = 5
bottomRight.layer.cornerCurve = .continuous
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true
let innerStack = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
bottomRight
])
attachmentStacks.add(innerStack)
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
innerStack,
])
attachmentStacks.add(outerStack)
fillView(outerStack)
sendSubviewToBack(outerStack)
NSLayoutConstraint.activate([
left.halfWidth(),
topRight.halfHeight(),
])
accessibilityElements.append(left)
accessibilityElements.append(topRight)
accessibilityElements.append(bottomRight)
case 4:
let topLeft = createAttachmentView(index: 0, hSize: .half, vSize: .half)
topLeft.layer.cornerRadius = 5
topLeft.layer.cornerCurve = .continuous
topLeft.layer.maskedCorners = .layerMinXMinYCorner
topLeft.layer.masksToBounds = true
let bottomLeft = createAttachmentView(index: 2, hSize: .half, vSize: .half)
bottomLeft.layer.cornerRadius = 5
bottomLeft.layer.cornerCurve = .continuous
bottomLeft.layer.maskedCorners = .layerMinXMaxYCorner
bottomLeft.layer.masksToBounds = true
let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topLeft,
bottomLeft
])
attachmentStacks.add(left)
let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half)
topRight.layer.cornerRadius = 5
topRight.layer.cornerCurve = .continuous
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let bottomRight = createAttachmentView(index: 3, hSize: .half, vSize: .half)
bottomRight.layer.cornerRadius = 5
bottomRight.layer.cornerCurve = .continuous
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true
let right = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
bottomRight
])
attachmentStacks.add(right)
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
right,
])
attachmentStacks.add(outerStack)
fillView(outerStack)
sendSubviewToBack(outerStack)
NSLayoutConstraint.activate([
left.halfWidth(),
topLeft.halfHeight(),
topRight.halfHeight(),
])
accessibilityElements.append(topLeft)
accessibilityElements.append(topRight)
accessibilityElements.append(bottomLeft)
accessibilityElements.append(bottomRight)
default: // more than 4
let moreView = UIView()
self.moreView = moreView
moreView.backgroundColor = .secondarySystemBackground
moreView.translatesAutoresizingMaskIntoConstraints = false
moreView.isUserInteractionEnabled = true
moreView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreViewTapped)))
moreView.layer.cornerRadius = 5
moreView.layer.cornerCurve = .continuous
moreView.layer.maskedCorners = .layerMaxXMaxYCorner
moreView.layer.masksToBounds = true
let moreLabel = UILabel()
moreLabel.text = "\(attachments.count - 3) more..."
moreLabel.textColor = .secondaryLabel
moreLabel.textAlignment = .center
moreLabel.translatesAutoresizingMaskIntoConstraints = false
moreView.addSubview(moreLabel)
moreView.accessibilityLabel = moreLabel.text
let topLeft = createAttachmentView(index: 0, hSize: .half, vSize: .half)
topLeft.layer.cornerRadius = 5
topLeft.layer.cornerCurve = .continuous
topLeft.layer.maskedCorners = .layerMinXMinYCorner
topLeft.layer.masksToBounds = true
let bottomLeft = createAttachmentView(index: 2, hSize: .half, vSize: .half)
bottomLeft.layer.cornerRadius = 5
bottomLeft.layer.maskedCorners = .layerMinXMaxYCorner
bottomLeft.layer.masksToBounds = true
let left = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topLeft,
bottomLeft
])
attachmentStacks.add(left)
let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half)
topRight.layer.cornerRadius = 5
topRight.layer.cornerCurve = .continuous
topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true
let right = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
moreView
])
attachmentStacks.add(right)
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left,
right,
])
attachmentStacks.add(outerStack)
fillView(outerStack)
sendSubviewToBack(outerStack)
NSLayoutConstraint.activate([
left.halfWidth(),
topLeft.halfHeight(),
topRight.halfHeight(),
moreView.leadingAnchor.constraint(equalTo: moreLabel.leadingAnchor),
moreLabel.trailingAnchor.constraint(equalTo: moreView.trailingAnchor),
moreView.topAnchor.constraint(equalTo: moreLabel.topAnchor),
moreLabel.bottomAnchor.constraint(equalTo: moreView.bottomAnchor),
])
accessibilityElements.append(topLeft)
accessibilityElements.append(topRight)
accessibilityElements.append(bottomLeft)
accessibilityElements.append(moreView)
}
self.aspectRatio = aspectRatio
} else {
self.isHidden = true
}
// Make sure accessibilityElements is set every time the UI is updated, otherwise it holds
// on to strong references to the old set of attachment views
self.accessibilityElements = accessibilityElements
}
private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView {
let attachmentView = AttachmentView(attachment: attachments[index], index: index)
attachmentView.delegate = delegate
attachmentView.translatesAutoresizingMaskIntoConstraints = false
attachmentView.accessibilityLabel = String(format: NSLocalizedString("Attachment %d", comment: "attachment at index accessiblity label"), index + 1)
attachmentView.accessibilityLabel = "Attachment \(index + 1)"
if let desc = attachments[index].description {
attachmentView.accessibilityLabel! += ", \(desc)"
}
attachmentViews.add(attachmentView)
return attachmentView
}
private func createAttachmentsStack(axis: NSLayoutConstraint.Axis, arrangedSubviews: [UIView]) -> UIStackView {
let stack = UIStackView(arrangedSubviews: arrangedSubviews)
stack.axis = axis
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}
private func createBlurView() {
let blur = UIBlurEffect(style: .dark)
let blurView = UIVisualEffectView(effect: blur)
blurView.alpha = 0
blurView.translatesAutoresizingMaskIntoConstraints = false
fillView(blurView)
let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blur, style: .label))
vibrancyView.translatesAutoresizingMaskIntoConstraints = false
fillView(vibrancyView, in: blurView.contentView)
blurView.contentView.addSubview(vibrancyView)
let image = UIImage(systemName: "eye")!
let imageView = UIImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel()
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.text = "Sensitive Content"
let stack = UIStackView(arrangedSubviews: [
imageView,
label
])
stack.axis = .vertical
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
vibrancyView.contentView.addSubview(stack)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor, multiplier: image.size.width / image.size.height),
imageView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.2),
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
stack.centerYAnchor.constraint(equalTo: centerYAnchor),
stack.widthAnchor.constraint(equalTo: widthAnchor)
])
self.blurView = blurView
blurView.isUserInteractionEnabled = true
blurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blurViewTapped)))
}
private func createHideButton() {
let blurEffect = UIBlurEffect(style: .regular)
let hideButtonBlurView = UIVisualEffectView(effect: blurEffect)
hideButtonBlurView.translatesAutoresizingMaskIntoConstraints = false
hideButtonBlurView.alpha = 1
hideButtonBlurView.isUserInteractionEnabled = true
hideButtonBlurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(hideButtonTapped)))
addSubview(hideButtonBlurView)
self.hideButtonView = hideButtonBlurView
let maskLayer = CALayer()
let image = UIImage(systemName: "eye.slash.fill")!
maskLayer.contents = image.cgImage!
maskLayer.frame = CGRect(origin: .zero, size: image.size)
hideButtonBlurView.layer.mask = maskLayer
let hideButtonVibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label))
hideButtonVibrancyView.translatesAutoresizingMaskIntoConstraints = false
hideButtonBlurView.contentView.addSubview(hideButtonVibrancyView)
let fillView = UIView()
fillView.translatesAutoresizingMaskIntoConstraints = false
fillView.backgroundColor = UIColor(displayP3Red: 0, green: 0, blue: 0, alpha: 0.5)
hideButtonVibrancyView.contentView.addSubview(fillView)
NSLayoutConstraint.activate([
hideButtonBlurView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
hideButtonBlurView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
hideButtonBlurView.widthAnchor.constraint(equalToConstant: image.size.width),
hideButtonBlurView.heightAnchor.constraint(equalToConstant: image.size.height),
hideButtonVibrancyView.leadingAnchor.constraint(equalTo: hideButtonBlurView.contentView.leadingAnchor),
hideButtonVibrancyView.trailingAnchor.constraint(equalTo: hideButtonBlurView.contentView.trailingAnchor),
hideButtonVibrancyView.topAnchor.constraint(equalTo: hideButtonBlurView.contentView.topAnchor),
hideButtonVibrancyView.bottomAnchor.constraint(equalTo: hideButtonBlurView.contentView.bottomAnchor),
fillView.leadingAnchor.constraint(equalTo: hideButtonBlurView.contentView.leadingAnchor),
fillView.trailingAnchor.constraint(equalTo: hideButtonBlurView.contentView.trailingAnchor),
fillView.topAnchor.constraint(equalTo: hideButtonBlurView.contentView.topAnchor),
fillView.bottomAnchor.constraint(equalTo: hideButtonBlurView.contentView.bottomAnchor),
])
}
private func fillView(_ view: UIView, in parentView: UIView? = nil) {
let parentView = parentView ?? self
parentView.addSubview(view)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
view.topAnchor.constraint(equalTo: parentView.topAnchor),
view.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
])
}
// MARK: - Interaction
@objc func blurViewTapped() {
UIView.animate(withDuration: 0.2) {
self.contentHidden = false
}
}
@objc func hideButtonTapped() {
UIView.animate(withDuration: 0.2) {
self.contentHidden = true
}
}
@objc func showSensitiveContent() {
guard let blurView = blurView else { return }
blurView.alpha = 1
UIView.animate(withDuration: 0.2) {
blurView.alpha = 0
}
}
@objc func hideSensitiveContent() {
guard let blurView = self.blurView else { return }
blurView.alpha = 0
UIView.animate(withDuration: 0.2) {
blurView.alpha = 1
}
}
@objc func moreViewTapped() {
guard attachments.count > 4 else { return }
// the more view shows up in place of the fourth attachemtn view, show tapping it should start at the fourth attachment
if let delegate = delegate,
let gallery = delegate.attachmentViewGallery(startingAt: 3) {
delegate.attachmentViewPresent(gallery, animated: true)
}
}
}
fileprivate enum RelativeSize {
case full, half
var multiplier: CGFloat {
switch self {
case .full:
return 1
case .half:
return 0.5
}
}
}
fileprivate extension UIView {
func halfWidth(spacing: CGFloat = 4) -> NSLayoutConstraint {
return widthAnchor.constraint(equalTo: superview!.widthAnchor, multiplier: 0.5, constant: -spacing / 2)
}
func halfHeight(spacing: CGFloat = 4) -> NSLayoutConstraint {
return heightAnchor.constraint(equalTo: superview!.heightAnchor, multiplier: 0.5, constant: -spacing / 2)
}
}
// A token that represents properties of attachments that the container needs to take into account when deciding whether to update
fileprivate struct AttachmentToken: Equatable {
let url: URL
// to show the alt badge or not
let hasDescription: Bool
init(attachment: Attachment) {
self.url = attachment.url
self.hasDescription = attachment.description != nil
}
}