* Refactor PhotoGridItem to be more compile-time safe * Add accessibilityLabel to items in PhotoLibrary * run auto genstrings * fix build issue Co-authored-by: Meher Kasam <meheranandk@gmail.com>
429 lines
16 KiB
Swift
429 lines
16 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import Photos
|
|
import PhotosUI
|
|
import SignalMessaging
|
|
|
|
protocol RecentPhotosDelegate: AnyObject {
|
|
var isMediaLibraryAccessGranted: Bool { get }
|
|
var isMediaLibraryAccessLimited: Bool { get }
|
|
func didSelectRecentPhoto(asset: PHAsset, attachment: SignalAttachment)
|
|
}
|
|
|
|
class RecentPhotosCollectionView: UICollectionView {
|
|
let maxRecentPhotos = 96
|
|
let spaceBetweenRows: CGFloat = 6
|
|
|
|
var isReadyForPhotoLibraryAccess: Bool {
|
|
return recentPhotosDelegate?.isMediaLibraryAccessGranted == true
|
|
}
|
|
|
|
var hasPhotos: Bool {
|
|
guard isReadyForPhotoLibraryAccess else { return false }
|
|
return collectionContents.assetCount > 0
|
|
}
|
|
weak var recentPhotosDelegate: RecentPhotosDelegate?
|
|
|
|
private var fetchingAttachmentIndex: IndexPath? {
|
|
didSet {
|
|
var indexPaths = [IndexPath]()
|
|
|
|
if let oldValue = oldValue {
|
|
indexPaths.append(oldValue)
|
|
}
|
|
if let newValue = fetchingAttachmentIndex {
|
|
indexPaths.append(newValue)
|
|
}
|
|
|
|
reloadItems(at: indexPaths)
|
|
}
|
|
}
|
|
|
|
private lazy var photoLibrary: PhotoLibrary = {
|
|
let library = PhotoLibrary()
|
|
library.add(delegate: self)
|
|
return library
|
|
}()
|
|
private lazy var collection = photoLibrary.defaultPhotoCollection()
|
|
private lazy var collectionContents = collection.contents(ascending: false, limit: maxRecentPhotos)
|
|
|
|
var itemSize: CGSize = .zero {
|
|
didSet {
|
|
guard oldValue != itemSize else { return }
|
|
updateLayout()
|
|
}
|
|
}
|
|
|
|
private var photoMediaSize: PhotoMediaSize {
|
|
let size = PhotoMediaSize()
|
|
size.thumbnailSize = itemSize
|
|
return size
|
|
}
|
|
|
|
private let collectionViewFlowLayout = UICollectionViewFlowLayout()
|
|
|
|
init() {
|
|
super.init(frame: .zero, collectionViewLayout: collectionViewFlowLayout)
|
|
|
|
dataSource = self
|
|
delegate = self
|
|
showsHorizontalScrollIndicator = false
|
|
|
|
contentInset = UIEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)
|
|
|
|
backgroundColor = .clear
|
|
|
|
register(RecentPhotoCell.self, forCellWithReuseIdentifier: RecentPhotoCell.reuseIdentifier)
|
|
register(SelectMorePhotosCell.self, forCellWithReuseIdentifier: SelectMorePhotosCell.reuseIdentifier)
|
|
|
|
collectionViewFlowLayout.scrollDirection = .horizontal
|
|
collectionViewFlowLayout.minimumLineSpacing = 6
|
|
collectionViewFlowLayout.minimumInteritemSpacing = spaceBetweenRows
|
|
|
|
updateLayout()
|
|
}
|
|
|
|
private func updateLayout() {
|
|
AssertIsOnMainThread()
|
|
|
|
// We don't want to do anything until media library permission is granted.
|
|
guard isReadyForPhotoLibraryAccess else { return }
|
|
guard itemSize.height > 0, itemSize.width > 0 else { return }
|
|
|
|
collectionViewFlowLayout.itemSize = itemSize
|
|
collectionViewFlowLayout.invalidateLayout()
|
|
|
|
reloadData()
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
extension RecentPhotosCollectionView: PhotoLibraryDelegate {
|
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
|
collectionContents = collection.contents(ascending: false, limit: maxRecentPhotos)
|
|
reloadData()
|
|
}
|
|
}
|
|
|
|
// MARK: - UICollectionViewDelegate
|
|
|
|
extension RecentPhotosCollectionView: UICollectionViewDelegate {
|
|
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
guard fetchingAttachmentIndex == nil else { return }
|
|
|
|
guard indexPath.row < collectionContents.assetCount else {
|
|
owsFailDebug("Asset does not exist.")
|
|
return
|
|
}
|
|
|
|
fetchingAttachmentIndex = indexPath
|
|
|
|
let asset = collectionContents.asset(at: indexPath.item)
|
|
collectionContents.outgoingAttachment(
|
|
for: asset
|
|
).done { [weak self] attachment in
|
|
self?.recentPhotosDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment)
|
|
}.ensure { [weak self] in
|
|
self?.fetchingAttachmentIndex = nil
|
|
}.catch { error in
|
|
Logger.error("Error: \(error)")
|
|
OWSActionSheets.showActionSheet(title: NSLocalizedString("IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS", comment: "alert title"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UICollectionViewDataSource
|
|
|
|
extension RecentPhotosCollectionView: UICollectionViewDataSource {
|
|
|
|
public func numberOfSections(in collectionView: UICollectionView) -> Int {
|
|
return 1
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
|
|
guard isReadyForPhotoLibraryAccess else { return 0 }
|
|
return collectionContents.assetCount + (recentPhotosDelegate?.isMediaLibraryAccessLimited == true ? 1 : 0)
|
|
}
|
|
|
|
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
guard indexPath.row < collectionContents.assetCount else {
|
|
// If the index is beyond the asset count, we should be rendering the "select more photos" prompt.
|
|
owsAssertDebug(recentPhotosDelegate?.isMediaLibraryAccessLimited == true)
|
|
|
|
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SelectMorePhotosCell.reuseIdentifier, for: indexPath) as? SelectMorePhotosCell else {
|
|
owsFail("cell was unexpectedly nil")
|
|
}
|
|
|
|
return cell
|
|
}
|
|
|
|
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RecentPhotoCell.reuseIdentifier, for: indexPath) as? RecentPhotoCell else {
|
|
owsFail("cell was unexpectedly nil")
|
|
}
|
|
|
|
let assetItem = collectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
|
cell.configure(item: assetItem, isLoading: fetchingAttachmentIndex == indexPath)
|
|
#if DEBUG
|
|
// These accessibilityIdentifiers won't be stable, but they
|
|
// should work for the purposes of our automated testing.
|
|
cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "recent-photo-\(indexPath.row)")
|
|
#endif
|
|
return cell
|
|
}
|
|
}
|
|
|
|
class SelectMorePhotosCell: UICollectionViewCell {
|
|
|
|
static let reuseIdentifier = "SelectMorePhotosCell"
|
|
|
|
override init(frame: CGRect) {
|
|
|
|
super.init(frame: frame)
|
|
|
|
clipsToBounds = true
|
|
layer.cornerRadius = 4
|
|
backgroundColor = Theme.washColor
|
|
|
|
// There's very little space for text here, so we stick with
|
|
// a fixed font size.
|
|
let fixedFont = UIFont.systemFont(ofSize: 13)
|
|
|
|
let titleLabel = UILabel()
|
|
titleLabel.numberOfLines = 0
|
|
titleLabel.lineBreakMode = .byWordWrapping
|
|
titleLabel.textAlignment = .center
|
|
titleLabel.font = fixedFont.ows_semibold
|
|
titleLabel.textColor = Theme.primaryTextColor
|
|
titleLabel.text = NSLocalizedString(
|
|
"IMAGE_PICKER_CHANGE_PHOTOS_TITLE",
|
|
comment: "Title show that the user has granted limited access to their photos and can change that in the Settings app."
|
|
)
|
|
|
|
let explanationLabel = UILabel()
|
|
explanationLabel.numberOfLines = 0
|
|
explanationLabel.lineBreakMode = .byWordWrapping
|
|
explanationLabel.textAlignment = .center
|
|
explanationLabel.font = fixedFont
|
|
explanationLabel.textColor = Theme.secondaryTextAndIconColor
|
|
explanationLabel.text = NSLocalizedString(
|
|
"IMAGE_PICKER_CHANGE_PHOTOS_EXPLANATION",
|
|
comment: "Explanation showing that the user has granted limited access to their photos and can change that in the Settings app."
|
|
)
|
|
|
|
let button = OWSFlatButton()
|
|
button.useDefaultCornerRadius()
|
|
button.setTitle(
|
|
title: NSLocalizedString(
|
|
"IMAGE_PICKER_CHANGE_PHOTOS",
|
|
comment: "Button that will present a view for the user to change the photos Signal has access to."
|
|
),
|
|
font: fixedFont,
|
|
titleColor: .ows_white
|
|
)
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12)
|
|
button.setBackgroundColors(upColor: .ows_accentBlue)
|
|
|
|
let buttonContainer = UIView()
|
|
buttonContainer.addSubview(button)
|
|
button.autoPinEdge(toSuperviewEdge: .top, withInset: 4)
|
|
button.autoPinEdge(toSuperviewEdge: .bottom, withInset: 4)
|
|
button.autoHCenterInSuperview()
|
|
button.autoMatch(.width, to: .width, of: buttonContainer, withOffset: 0, relation: .lessThanOrEqual)
|
|
button.setPressedBlock {
|
|
guard #available(iOS 14, *),
|
|
let frontmostVC = CurrentAppContext().frontmostViewController() else { return }
|
|
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: frontmostVC)
|
|
}
|
|
|
|
let topSpacer = UIView.vStretchingSpacer()
|
|
let bottomSpacer = UIView.vStretchingSpacer()
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [topSpacer, titleLabel, explanationLabel, buttonContainer, bottomSpacer])
|
|
stackView.axis = .vertical
|
|
stackView.spacing = 4
|
|
stackView.isLayoutMarginsRelativeArrangement = true
|
|
stackView.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)
|
|
|
|
topSpacer.autoMatch(.height, to: .height, of: bottomSpacer)
|
|
|
|
contentView.addSubview(stackView)
|
|
stackView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
|
|
@available(*, unavailable, message: "Unimplemented")
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
}
|
|
|
|
class RecentPhotoCell: UICollectionViewCell {
|
|
|
|
static let reuseIdentifier = "RecentPhotoCell"
|
|
|
|
let imageView = UIImageView()
|
|
private var contentTypeBadgeView: UIImageView?
|
|
private var durationLabel: UILabel?
|
|
private var durationLabelBackground: UIView?
|
|
let loadingIndicator = UIActivityIndicatorView(style: .whiteLarge)
|
|
|
|
var item: PhotoGridItem?
|
|
|
|
override init(frame: CGRect) {
|
|
|
|
super.init(frame: frame)
|
|
|
|
imageView.contentMode = .scaleAspectFill
|
|
clipsToBounds = true
|
|
layer.cornerRadius = 4
|
|
|
|
contentView.addSubview(imageView)
|
|
imageView.autoPinEdgesToSuperviewEdges()
|
|
|
|
loadingIndicator.layer.shadowColor = UIColor.black.cgColor
|
|
loadingIndicator.layer.shadowOffset = .zero
|
|
loadingIndicator.layer.shadowOpacity = 0.7
|
|
loadingIndicator.layer.shadowRadius = 3.0
|
|
|
|
contentView.addSubview(loadingIndicator)
|
|
loadingIndicator.autoCenterInSuperview()
|
|
}
|
|
|
|
@available(*, unavailable, message: "Unimplemented")
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
|
|
if let durationLabel = durationLabel,
|
|
previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
|
|
durationLabel.font = RecentPhotoCell.durationLabelFont()
|
|
}
|
|
}
|
|
|
|
var image: UIImage? {
|
|
get { return imageView.image }
|
|
set {
|
|
imageView.image = newValue
|
|
imageView.backgroundColor = newValue == nil ? Theme.washColor : .clear
|
|
}
|
|
}
|
|
|
|
private static func durationLabelFont() -> UIFont {
|
|
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .caption1)
|
|
return UIFont.ows_semiboldFont(withSize: max(12, fontDescriptor.pointSize))
|
|
}
|
|
|
|
private func setContentTypeBadge(image: UIImage?) {
|
|
guard image != nil else {
|
|
contentTypeBadgeView?.isHidden = true
|
|
return
|
|
}
|
|
|
|
if contentTypeBadgeView == nil {
|
|
let contentTypeBadgeView = UIImageView()
|
|
contentView.addSubview(contentTypeBadgeView)
|
|
contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 4)
|
|
contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 4)
|
|
self.contentTypeBadgeView = contentTypeBadgeView
|
|
}
|
|
contentTypeBadgeView?.isHidden = false
|
|
contentTypeBadgeView?.image = image
|
|
contentTypeBadgeView?.sizeToFit()
|
|
}
|
|
|
|
private func setMedia(itemType: PhotoGridItemType) {
|
|
guard case .video(let duration) = itemType else {
|
|
durationLabel?.isHidden = true
|
|
durationLabelBackground?.isHidden = true
|
|
return
|
|
}
|
|
|
|
if durationLabel == nil {
|
|
let durationLabel = UILabel()
|
|
durationLabel.textColor = .white
|
|
durationLabel.font = RecentPhotoCell.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: .ows_blackAlpha40, to: .clear)
|
|
gradientView.gradientLayer.type = .radial
|
|
gradientView.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
|
gradientView.gradientLayer.endPoint = CGPoint(x: 0, y: 90/122) // 122 x 58 oval
|
|
self.durationLabelBackground = gradientView
|
|
}
|
|
|
|
guard let durationLabel = durationLabel, let durationLabelBackground = 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.autoPinEdge(.top, to: .top, of: durationLabel, withOffset: -10)
|
|
durationLabelBackground.autoPinEdge(.leading, to: .leading, of: durationLabel, withOffset: -24)
|
|
durationLabelBackground.centerXAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
|
|
durationLabelBackground.centerYAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
|
|
}
|
|
|
|
durationLabel.isHidden = false
|
|
durationLabelBackground.isHidden = false
|
|
durationLabel.text = OWSFormat.localizedDurationString(from: duration)
|
|
durationLabel.sizeToFit()
|
|
}
|
|
|
|
public func configure(item: PhotoGridItem, isLoading: Bool) {
|
|
self.item = item
|
|
|
|
image = item.asyncThumbnail { [weak self] image in
|
|
guard let self = self, let currentItem = self.item, currentItem === item else { return }
|
|
self.image = image
|
|
}
|
|
|
|
setMedia(itemType: item.type)
|
|
|
|
switch item.type {
|
|
case .animated:
|
|
setContentTypeBadge(image: #imageLiteral(resourceName: "ic_gallery_badge_gif"))
|
|
case .photo, .video:
|
|
setContentTypeBadge(image: nil)
|
|
}
|
|
|
|
if isLoading { startLoading() }
|
|
}
|
|
|
|
override public func prepareForReuse() {
|
|
super.prepareForReuse()
|
|
|
|
item = nil
|
|
imageView.image = nil
|
|
stopLoading()
|
|
}
|
|
|
|
func startLoading() {
|
|
loadingIndicator.startAnimating()
|
|
}
|
|
|
|
func stopLoading() {
|
|
loadingIndicator.stopAnimating()
|
|
}
|
|
}
|