// // 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 = .weakObjects() let attachmentStacks: NSHashTable = .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 } }