mastodon-ios/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift

497 lines
22 KiB
Swift

//
// MosaicImageViewContainer.swift
// Mastodon
//
// Created by Cirno MainasuK on 2021-2-23.
//
import os.log
import func AVFoundation.AVMakeRect
import UIKit
protocol MosaicImageViewContainerPresentable: AnyObject {
var mosaicImageViewContainer: MosaicImageViewContainer { get }
var isRevealing: Bool { get }
}
protocol MosaicImageViewContainerDelegate: AnyObject {
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
}
final class MosaicImageViewContainer: UIView {
weak var delegate: MosaicImageViewContainerDelegate?
let container = UIStackView()
private(set) lazy var imageViews: [UIImageView] = {
(0..<4).map { _ -> UIImageView in
let imageView = UIImageView()
imageView.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.photoTapGestureRecognizerHandler(_:)))
imageView.addGestureRecognizer(tapGesture)
imageView.isAccessibilityElement = true
imageView.backgroundColor = .systemFill
return imageView
}
}()
let blurhashOverlayImageViews: [UIImageView] = {
(0..<4).map { _ in UIImageView() }
}()
let contentWarningOverlayView: ContentWarningOverlayView = {
let contentWarningOverlayView = ContentWarningOverlayView()
contentWarningOverlayView.configure(style: .media)
return contentWarningOverlayView
}()
private var containerHeightLayoutConstraint: NSLayoutConstraint!
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate {
func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) {
self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
}
extension MosaicImageViewContainer {
private func _init() {
// accessibility
accessibilityIgnoresInvertColors = true
container.translatesAutoresizingMaskIntoConstraints = false
container.axis = .horizontal
container.distribution = .fillEqually
addSubview(container)
containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1)
NSLayoutConstraint.activate([
container.topAnchor.constraint(equalTo: topAnchor),
container.leadingAnchor.constraint(equalTo: leadingAnchor),
trailingAnchor.constraint(equalTo: container.trailingAnchor),
bottomAnchor.constraint(equalTo: container.bottomAnchor),
containerHeightLayoutConstraint
])
contentWarningOverlayView.delegate = self
}
}
extension MosaicImageViewContainer {
func resetImageTask() {
imageViews.forEach { imageView in
imageView.af.cancelImageRequest()
imageView.image = nil
}
}
func reset() {
resetImageTask()
container.arrangedSubviews.forEach { subview in
container.removeArrangedSubview(subview)
subview.removeFromSuperview()
}
container.subviews.forEach { subview in
subview.removeFromSuperview()
}
imageViews.forEach { imageView in
imageView.constraints.forEach { imageView.removeConstraint($0) }
imageView.removeFromSuperview()
imageView.layer.maskedCorners = [
.layerMinXMinYCorner, .layerMaxXMinYCorner,
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
]
imageView.image = nil
}
blurhashOverlayImageViews.forEach { imageView in
imageView.constraints.forEach { imageView.removeConstraint($0) }
imageView.removeFromSuperview()
imageView.layer.maskedCorners = [
.layerMinXMinYCorner, .layerMaxXMinYCorner,
.layerMinXMaxYCorner, .layerMaxXMaxYCorner
]
imageView.image = nil
}
contentWarningOverlayView.removeFromSuperview()
contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect
contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0
contentWarningOverlayView.isUserInteractionEnabled = true
container.spacing = UIView.separatorLineHeight(of: self) * 2 // 2px
}
struct ConfigurableMosaic {
let imageView: UIImageView
let blurhashOverlayImageView: UIImageView
let imageViewSize: CGSize
}
func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic {
reset()
let contentView = UIView()
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addArrangedSubview(contentView)
let imageViewSize: CGSize = {
let rect = AVMakeRect(
aspectRatio: aspectRatio,
insideRect: CGRect(origin: .zero, size: maxSize)
).integral
return rect.size
}()
let imageViewFrame = CGRect(origin: .zero, size: imageViewSize)
let imageView = imageViews[0]
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
imageView.widthAnchor.constraint(equalToConstant: imageViewFrame.width).priority(.required - 1),
])
containerHeightLayoutConstraint.constant = imageViewFrame.height
containerHeightLayoutConstraint.isActive = true
let blurhashOverlayImageView = blurhashOverlayImageViews[0]
blurhashOverlayImageView.layer.masksToBounds = true
blurhashOverlayImageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
blurhashOverlayImageView.layer.cornerCurve = .continuous
blurhashOverlayImageView.contentMode = .scaleAspectFill
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(blurhashOverlayImageView)
NSLayoutConstraint.activate([
blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor),
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
])
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor),
contentWarningOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
])
return ConfigurableMosaic(
imageView: imageView,
blurhashOverlayImageView: blurhashOverlayImageView,
imageViewSize: imageViewSize
)
}
func setupImageViews(count: Int, maxSize: CGSize) -> [ConfigurableMosaic] {
reset()
guard count > 1 else {
return []
}
let maxHeight = maxSize.height
let spacing: CGFloat = 1
containerHeightLayoutConstraint.constant = maxHeight
containerHeightLayoutConstraint.isActive = true
let contentLeftStackView = UIStackView()
let contentRightStackView = UIStackView()
[contentLeftStackView, contentRightStackView].forEach { stackView in
stackView.axis = .vertical
stackView.distribution = .fillEqually
stackView.spacing = spacing
}
container.addArrangedSubview(contentLeftStackView)
container.addArrangedSubview(contentRightStackView)
let imageViews: [UIImageView] = (0..<count).map { i in self.imageViews[i] }
let blurhashOverlayImageViews: [UIImageView] = (0..<count).map { i in self.blurhashOverlayImageViews[i] }
imageViews.forEach { imageView in
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
}
blurhashOverlayImageViews.forEach { imageView in
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
}
if count == 2 {
contentLeftStackView.addArrangedSubview(imageViews[0])
contentRightStackView.addArrangedSubview(imageViews[1])
switch UIApplication.shared.userInterfaceLayoutDirection {
case .rightToLeft:
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
default:
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
}
} else if count == 3 {
contentLeftStackView.addArrangedSubview(imageViews[0])
contentRightStackView.addArrangedSubview(imageViews[1])
contentRightStackView.addArrangedSubview(imageViews[2])
switch UIApplication.shared.userInterfaceLayoutDirection {
case .rightToLeft:
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
default:
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
}
} else if count == 4 {
contentLeftStackView.addArrangedSubview(imageViews[0])
contentRightStackView.addArrangedSubview(imageViews[1])
contentLeftStackView.addArrangedSubview(imageViews[2])
contentRightStackView.addArrangedSubview(imageViews[3])
switch UIApplication.shared.userInterfaceLayoutDirection {
case .rightToLeft:
imageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner]
imageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
imageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMaxXMinYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMinXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMaxXMaxYCorner]
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMinXMaxYCorner]
default:
imageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
imageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
imageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
imageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
blurhashOverlayImageViews[0].layer.maskedCorners = [.layerMinXMinYCorner]
blurhashOverlayImageViews[1].layer.maskedCorners = [.layerMaxXMinYCorner]
blurhashOverlayImageViews[2].layer.maskedCorners = [.layerMinXMaxYCorner]
blurhashOverlayImageViews[3].layer.maskedCorners = [.layerMaxXMaxYCorner]
}
}
for (imageView, blurhashOverlayImageView) in zip(imageViews, blurhashOverlayImageViews) {
blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(blurhashOverlayImageView)
NSLayoutConstraint.activate([
blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor),
blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
])
}
contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentWarningOverlayView)
NSLayoutConstraint.activate([
contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor),
contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
var mosaics: [ConfigurableMosaic] = []
for (i, (imageView, blurhashOverlayImageView)) in zip(imageViews, blurhashOverlayImageViews).enumerated() {
let imageViewSize: CGSize = {
switch (i, count) {
case (_, 4):
return CGSize(width: maxSize.width * 0.5 - spacing, height: maxSize.height * 0.5 - spacing)
case (i, 3):
let width = maxSize.width * 0.5 - spacing
if i == 0 {
return CGSize(width: width, height: maxSize.height)
} else {
return CGSize(width: width, height: maxSize.height * 0.5 - spacing)
}
case (_, 2):
let width = maxSize.width * 0.5 - spacing
return CGSize(width: width, height: maxSize.height)
default:
assertionFailure()
return maxSize
}
}()
imageView.frame.size = imageViewSize
let mosaic = ConfigurableMosaic(
imageView: imageView,
blurhashOverlayImageView: blurhashOverlayImageView,
imageViewSize: imageViewSize
)
mosaics.append(mosaic)
}
return mosaics
}
}
// FIXME: refactor blurhash image and preview image
extension MosaicImageViewContainer {
func setImageViews(alpha: CGFloat) {
// blurhashOverlayImageViews.forEach { $0.alpha = alpha }
imageViews.forEach { $0.alpha = alpha }
}
func setImageView(alpha: CGFloat, index: Int) {
// if index < blurhashOverlayImageViews.count {
// blurhashOverlayImageViews[index].alpha = alpha
// }
if index < imageViews.count {
imageViews[index].alpha = alpha
}
}
func thumbnail(at index: Int) -> UIImage? {
guard blurhashOverlayImageViews.count == imageViews.count else { return nil }
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
guard index < tuples.count else { return nil }
let tuple = tuples[index]
return tuple.1.image ?? tuple.0.image
}
func thumbnails() -> [UIImage?] {
guard blurhashOverlayImageViews.count == imageViews.count else { return [] }
let tuples = Array(zip(blurhashOverlayImageViews, imageViews))
return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in
return imageView.image ?? blurhashOverlayImageView.image
}
}
}
extension MosaicImageViewContainer {
@objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView)
}
@objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
guard let imageView = sender.view as? UIImageView else { return }
guard let index = imageViews.firstIndex(of: imageView) else { return }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: tap photo at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index)
delegate?.mosaicImageViewContainer(self, didTapImageView: imageView, atIndex: index)
}
}
#if DEBUG && canImport(SwiftUI)
import SwiftUI
struct MosaicImageView_Previews: PreviewProvider {
static var images: [UIImage] {
return ["bradley-dunn", "mrdongok", "lucas-ludwig", "markus-spiske"]
.map { UIImage(named: $0)! }
}
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let image = images[3]
let mosaic = view.setupImageView(
aspectRatio: image.size,
maxSize: CGSize(width: 375, height: 400)
)
mosaic.imageView.image = image
return view
}
.previewLayout(.fixed(width: 375, height: 400))
.previewDisplayName("Portrait - one image")
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let image = images[1]
let mosaic = view.setupImageView(
aspectRatio: image.size,
maxSize: CGSize(width: 375, height: 400)
)
mosaic.imageView.layer.masksToBounds = true
mosaic.imageView.layer.cornerRadius = 8
mosaic.imageView.contentMode = .scaleAspectFill
mosaic.imageView.image = image
return view
}
.previewLayout(.fixed(width: 375, height: 400))
.previewDisplayName("Landscape - one image")
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let images = self.images.prefix(2)
let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162))
for (i, mosaic) in mosaics.enumerated() {
mosaic.imageView.image = images[i]
}
return view
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("two image")
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let images = self.images.prefix(3)
let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162))
for (i, mosaic) in mosaics.enumerated() {
mosaic.imageView.image = images[i]
}
return view
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("three image")
UIViewPreview(width: 375) {
let view = MosaicImageViewContainer()
let images = self.images.prefix(4)
let mosaics = view.setupImageViews(count: images.count, maxSize: CGSize(width: 375, height: 162))
for (i, mosaic) in mosaics.enumerated() {
mosaic.imageView.image = images[i]
}
return view
}
.previewLayout(.fixed(width: 375, height: 200))
.previewDisplayName("four image")
}
}
}
#endif