Path: blob/main/Signal/src/views/PhotoGridViewCell.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import UIKit
enum PhotoGridItemType {
case photo
case animated
case video(Promise<TimeInterval>)
var localizedString: String {
switch self {
case .photo:
return CommonStrings.attachmentTypePhoto
case .animated:
return CommonStrings.attachmentTypeAnimated
case .video(let promise):
switch promise.result {
case .failure, .none:
return "\(CommonStrings.attachmentTypeVideo)"
case .success(let value):
return "\(CommonStrings.attachmentTypeVideo) \(OWSFormat.localizedDurationString(from: value))"
}
}
}
var formattedType: String {
switch self {
case .animated:
return OWSLocalizedString(
"ALL_MEDIA_THUMBNAIL_LABEL_GIF",
comment: "Label shown over thumbnails of GIFs in the All Media view",
)
case .photo:
return OWSLocalizedString(
"ALL_MEDIA_THUMBNAIL_LABEL_IMAGE",
comment: "Label shown by thumbnails of images in the All Media view",
)
case .video:
return OWSLocalizedString(
"ALL_MEDIA_THUMBNAIL_LABEL_VIDEO",
comment: "Label shown by thumbnails of videos in the All Media view",
)
}
}
}
public struct MediaMetadata {
var sender: String
var abbreviatedSender: String
var byteSize: Int
var creationDate: Date?
}
protocol PhotoGridItem: AnyObject {
var type: PhotoGridItemType { get }
func asyncThumbnail(completion: @escaping (UIImage?) -> Void)
var mediaMetadata: MediaMetadata? { get }
}
class PhotoGridViewCell: UICollectionViewCell {
static let reuseIdentifier = "PhotoGridViewCell"
let imageView: UIImageView
private var durationLabel: UILabel?
private var durationLabelBackground: UIView?
private let selectionButton = SelectionButton()
private let highlightedMaskView: UIView
private let selectedMaskView: UIView
private(set) var photoGridItem: PhotoGridItem?
var loadingColor = Theme.washColor
var allowsMultipleSelection = false {
didSet {
updateSelectionState()
}
}
override var isHighlighted: Bool {
didSet {
highlightedMaskView.isHidden = !isHighlighted
}
}
override var isSelected: Bool {
didSet {
updateSelectionState()
}
}
override init(frame: CGRect) {
imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
highlightedMaskView = UIView()
highlightedMaskView.alpha = 0.2
highlightedMaskView.backgroundColor = Theme.darkThemePrimaryColor
highlightedMaskView.isHidden = true
selectedMaskView = UIView()
selectedMaskView.alpha = 0.3
selectedMaskView.backgroundColor = Theme.darkThemeBackgroundColor
selectedMaskView.isHidden = true
super.init(frame: frame)
clipsToBounds = true
contentView.addSubview(imageView)
contentView.addSubview(highlightedMaskView)
contentView.addSubview(selectedMaskView)
contentView.addSubview(selectionButton)
imageView.autoPinEdgesToSuperviewEdges()
highlightedMaskView.autoPinEdgesToSuperviewEdges()
selectedMaskView.autoPinEdgesToSuperviewEdges()
selectionButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 5)
selectionButton.autoPinEdge(toSuperviewEdge: .top, withInset: 5)
}
@available(*, unavailable, message: "Unimplemented")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateSelectionState() {
selectedMaskView.isHidden = !isSelected
selectionButton.isSelected = isSelected
selectionButton.allowsMultipleSelection = allowsMultipleSelection
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if
let durationLabel,
previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory
{
durationLabel.font = Self.durationLabelFont()
}
}
var image: UIImage? {
get { return imageView.image }
set {
imageView.image = newValue
imageView.backgroundColor = newValue == nil ? loadingColor : .clear
}
}
private static func durationLabelFont() -> UIFont {
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .caption1)
return UIFont.semiboldFont(ofSize: max(12, fontDescriptor.pointSize))
}
private func setMedia(itemType: PhotoGridItemType) {
hideVideoDuration()
switch itemType {
case .video(let promisedDuration):
updateVideoDurationWhenPromiseFulfilled(promisedDuration)
case .animated:
setCaption(itemType.formattedType)
case .photo:
break
}
}
private func updateVideoDurationWhenPromiseFulfilled(_ promisedDuration: Promise<TimeInterval>) {
let originalItem = photoGridItem
promisedDuration.observe { [weak self] result in
guard let self, self.photoGridItem === originalItem, case .success(let duration) = result else {
return
}
self.setCaption(OWSFormat.localizedDurationString(from: duration))
}
}
private func hideVideoDuration() {
durationLabel?.isHidden = true
durationLabelBackground?.isHidden = true
}
private func setCaption(_ caption: String) {
if durationLabel == nil {
let durationLabel = UILabel()
durationLabel.textColor = .white
durationLabel.font = Self.durationLabelFont()
durationLabel.layer.shadowColor = UIColor.ows_blackAlpha20.cgColor
durationLabel.layer.shadowOffset = CGSize(width: -1, height: -1)
durationLabel.layer.shadowOpacity = 1
durationLabel.layer.shadowRadius = 4
durationLabel.shadowOffset = CGSize(width: 0, height: 1)
durationLabel.adjustsFontForContentSizeCategory = true
self.durationLabel = durationLabel
}
if durationLabelBackground == nil {
let gradientView = GradientView(from: .clear, to: .ows_blackAlpha60)
gradientView.gradientLayer.type = .axial
gradientView.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
gradientView.gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
self.durationLabelBackground = gradientView
}
guard let durationLabel, let durationLabelBackground else {
return
}
if durationLabel.superview == nil {
contentView.addSubview(durationLabel)
durationLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: 6)
durationLabel.autoPinEdge(toSuperviewEdge: .bottom, withInset: 4)
}
if durationLabelBackground.superview == nil {
contentView.insertSubview(durationLabelBackground, belowSubview: durationLabel)
durationLabelBackground.topAnchor.constraint(equalTo: centerYAnchor).isActive = true
durationLabelBackground.autoPinEdge(toSuperviewEdge: .leading)
durationLabelBackground.autoPinEdge(toSuperviewEdge: .trailing)
durationLabelBackground.autoPinEdge(toSuperviewEdge: .bottom)
}
durationLabel.isHidden = false
durationLabelBackground.isHidden = false
durationLabel.text = caption
durationLabel.sizeToFit()
}
private func setUpAccessibility(item: PhotoGridItem?) {
self.isAccessibilityElement = true
if let item {
self.accessibilityLabel = [
item.type.localizedString,
MediaTileDateFormatter.formattedDateString(for: item.mediaMetadata?.creationDate),
]
.compactMap { $0 }
.joined(separator: ", ")
} else {
self.accessibilityLabel = ""
}
}
func makePlaceholder() {
photoGridItem = nil
image = nil
setMedia(itemType: .photo)
setUpAccessibility(item: nil)
}
func configure(item: PhotoGridItem) {
photoGridItem = item
// PHCachingImageManager returns multiple progressively better
// thumbnails in the async block. We want to avoid calling
// `configure(item:)` multiple times because the high-quality image eventually applied
// last time it was called will be momentarily replaced by a progression of lower
// quality images.
image = nil
item.asyncThumbnail { [weak self] image in
guard let self else { return }
guard let currentItem = self.photoGridItem else {
return
}
guard currentItem === item else {
return
}
if image == nil {
Logger.debug("image == nil")
}
self.image = image
}
setMedia(itemType: item.type)
setUpAccessibility(item: item)
}
override func prepareForReuse() {
super.prepareForReuse()
photoGridItem = nil
imageView.image = nil
durationLabel?.isHidden = true
durationLabelBackground?.isHidden = true
highlightedMaskView.isHidden = true
selectedMaskView.isHidden = true
selectionButton.reset()
}
func mediaPresentationContext(collectionView: UICollectionView, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
guard let mediaSuperview = imageView.superview else {
owsFailDebug("mediaSuperview was unexpectedly nil")
return nil
}
let presentationFrame = coordinateSpace.convert(imageView.frame, from: mediaSuperview)
let clippingAreaInsets = UIEdgeInsets(
top: collectionView.adjustedContentInset.top,
leading: 0,
bottom: collectionView.adjustedContentInset.bottom,
trailing: 0,
)
return MediaPresentationContext(
mediaView: imageView,
presentationFrame: presentationFrame,
clippingAreaInsets: clippingAreaInsets,
)
}
}