feat: implement image media content warning overlay

This commit is contained in:
CMK 2021-02-25 13:47:30 +08:00
parent ccc8741ccd
commit 414aa086b4
13 changed files with 178 additions and 28 deletions

4
.gitignore vendored
View File

@ -119,5 +119,5 @@ xcuserdata
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
Mastodon/Localization/StringsConvertor/input
Mastodon/Localization/StringsConvertor/output
Localization/StringsConvertor/input
Localization/StringsConvertor/output

View File

@ -29,8 +29,9 @@
},
"status": {
"user_boosted": "%s boosted",
"content_warning": "content warning",
"show_post": "Show Post"
"show_post": "Show Post",
"status_content_warning": "content warning",
"media_content_warning": "Tap to reveal that may be sensitive"
},
"timeline": {
"load_more": "Load More"

View File

@ -26,24 +26,30 @@ enum Item {
protocol StatusContentWarningAttribute {
var isStatusTextSensitive: Bool { get set }
var isStatusSensitive: Bool { get set }
}
extension Item {
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute {
var isStatusTextSensitive: Bool = false
var isStatusTextSensitive: Bool
var isStatusSensitive: Bool
public init(
isStatusTextSensitive: Bool
isStatusTextSensitive: Bool,
isStatusSensitive: Bool
) {
self.isStatusTextSensitive = isStatusTextSensitive
self.isStatusSensitive = isStatusSensitive
}
static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool {
return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive
return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
lhs.isStatusSensitive == rhs.isStatusSensitive
}
func hash(into hasher: inout Hasher) {
hasher.combine(isStatusTextSensitive)
hasher.combine(isStatusSensitive)
}
}

View File

@ -94,14 +94,18 @@ extension StatusSection {
// set text
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)
// set content warning
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? (toot.reblog ?? toot).sensitive
// set status text content warning
let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
cell.statusView.contentWarningTitle.text = (toot.reblog ?? toot).spoilerText.flatMap { spoilerText in
guard !spoilerText.isEmpty else { return nil }
return L10n.Common.Controls.Status.contentWarning + ": \(spoilerText)"
} ?? L10n.Common.Controls.Status.contentWarning
cell.statusView.contentWarningTitle.text = {
if spoilerText.isEmpty {
return L10n.Common.Controls.Status.statusContentWarning
} else {
return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)"
}
}()
// prepare media attachments
let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
@ -146,6 +150,9 @@ extension StatusSection {
}
}
cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty
let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
// toolbar
let replyCountTitle: String = {

View File

@ -56,10 +56,12 @@ internal enum L10n {
internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto")
}
internal enum Status {
/// content warning
internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning")
/// Tap to reveal that may be sensitive
internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning")
/// Show Post
internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost")
/// content warning
internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning")
/// %@ boosted
internal static func userBoosted(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1))

View File

@ -43,3 +43,39 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
}
}
extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
}
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
guard let diffableDataSource = self.tableViewDiffableDataSource else { return }
item(for: cell, indexPath: nil)
.receive(on: DispatchQueue.main)
.sink { [weak self] item in
guard let _ = self else { return }
guard let item = item else { return }
switch item {
case .homeTimelineIndex(_, let attribute):
attribute.isStatusSensitive = false
case .toot(_, let attribute):
attribute.isStatusSensitive = false
default:
return
}
var snapshot = diffableDataSource.snapshot()
snapshot.reloadItems([item])
UIView.animate(withDuration: 0.33) {
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0
} completion: { _ in
diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
}
.store(in: &cell.disposeBag)
}
}

View File

@ -15,8 +15,9 @@
"Common.Controls.Actions.SignIn" = "Sign in";
"Common.Controls.Actions.SignUp" = "Sign up";
"Common.Controls.Actions.TakePhoto" = "Take photo";
"Common.Controls.Status.ContentWarning" = "content warning";
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
"Common.Controls.Status.ShowPost" = "Show Post";
"Common.Controls.Status.StatusContentWarning" = "content warning";
"Common.Controls.Status.UserBoosted" = "%@ boosted";
"Common.Controls.Timeline.LoadMore" = "Load More";
"Common.Countable.Photo.Multiple" = "photos";

View File

@ -83,7 +83,12 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate {
var newTimelineItems: [Item] = []
for (i, timelineIndex) in timelineIndexes.enumerated() {
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: timelineIndex.toot.sensitive)
let toot = timelineIndex.toot.reblog ?? timelineIndex.toot
let isStatusTextSensitive: Bool = {
guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive)
// append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))

View File

@ -13,7 +13,7 @@ import GameplayKit
import os.log
import UIKit
final class PublicTimelineViewController: UIViewController, NeedsDependency, StatusTableViewCellDelegate {
final class PublicTimelineViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -203,3 +203,6 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat
}
}
}
// MARK: - StatusTableViewCellDelegate
extension PublicTimelineViewController: StatusTableViewCellDelegate { }

View File

@ -58,7 +58,12 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
var items = [Item]()
for (_, toot) in indexTootTuples {
let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: toot.sensitive)
let targetToot = toot.reblog ?? toot
let isStatusTextSensitive: Bool = {
guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false }
return true
}()
let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive)
items.append(Item.toot(objectID: toot.objectID, attribute: attribute))
if tootIDsWhichHasGap.contains(toot.id) {
items.append(Item.publicMiddleLoader(tootID: toot.id))

View File

@ -13,18 +13,21 @@ protocol MosaicImageViewContainerPresentable: class {
var mosaicImageViewContainer: MosaicImageViewContainer { get }
}
protocol MosaicImageViewDelegate: class {
protocol MosaicImageViewContainerDelegate: class {
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
}
final class MosaicImageViewContainer: UIView {
static let cornerRadius: CGFloat = 4
static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
weak var delegate: MosaicImageViewDelegate?
weak var delegate: MosaicImageViewContainerDelegate?
let container = UIStackView()
var imageViews = [UIImageView]() {
var imageViews: [UIImageView] = [] {
didSet {
imageViews.forEach { imageView in
imageView.isUserInteractionEnabled = true
@ -34,7 +37,16 @@ final class MosaicImageViewContainer: UIView {
}
}
}
let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect)
let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect))
let contentWarningLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
label.text = L10n.Common.Controls.Status.mediaContentWarning
label.textAlignment = .center
return label
}()
private var containerHeightLayoutConstraint: NSLayoutConstraint!
override init(frame: CGRect) {
@ -53,6 +65,8 @@ extension MosaicImageViewContainer {
private func _init() {
container.translatesAutoresizingMaskIntoConstraints = false
container.axis = .horizontal
container.distribution = .fillEqually
addSubview(container)
containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1)
NSLayoutConstraint.activate([
@ -63,8 +77,32 @@ extension MosaicImageViewContainer {
containerHeightLayoutConstraint
])
container.axis = .horizontal
container.distribution = .fillEqually
// add blur visual effect view in the setup method
blurVisualEffectView.layer.masksToBounds = true
blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
blurVisualEffectView.layer.cornerCurve = .continuous
vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView)
NSLayoutConstraint.activate([
vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor),
vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor),
vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor),
vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor),
])
contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel)
NSLayoutConstraint.activate([
contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor),
contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor),
contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor),
])
blurVisualEffectView.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:)))
blurVisualEffectView.addGestureRecognizer(tapGesture)
}
}
@ -79,6 +117,9 @@ extension MosaicImageViewContainer {
container.subviews.forEach { subview in
subview.removeFromSuperview()
}
blurVisualEffectView.removeFromSuperview()
blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect
vibrancyVisualEffectView.alpha = 1.0
imageViews = []
container.spacing = 1
@ -100,6 +141,7 @@ extension MosaicImageViewContainer {
imageViews.append(imageView)
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius
imageView.layer.cornerCurve = .continuous
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
@ -112,6 +154,15 @@ extension MosaicImageViewContainer {
])
containerHeightLayoutConstraint.constant = floor(rect.height)
containerHeightLayoutConstraint.isActive = true
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
])
return imageView
}
@ -191,18 +242,34 @@ extension MosaicImageViewContainer {
}
}
blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurVisualEffectView)
NSLayoutConstraint.activate([
blurVisualEffectView.topAnchor.constraint(equalTo: container.topAnchor),
blurVisualEffectView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
blurVisualEffectView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
blurVisualEffectView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
return imageViews
}
}
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, didTapContentWarningVisualEffectView: blurVisualEffectView)
}
@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)

View File

@ -89,7 +89,7 @@ final class StatusView: UIView {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Common.Controls.Status.contentWarning
label.text = L10n.Common.Controls.Status.statusContentWarning
return label
}()
let contentWarningActionButton: UIButton = {

View File

@ -14,6 +14,9 @@ import Combine
protocol StatusTableViewCellDelegate: class {
func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView)
}
final class StatusTableViewCell: UITableViewCell {
@ -79,10 +82,11 @@ extension StatusTableViewCell {
bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh),
])
bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
statusView.delegate = self
statusView.statusMosaicImageView.delegate = self
statusView.actionToolbarContainer.delegate = self
bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
}
}
@ -94,6 +98,19 @@ extension StatusTableViewCell: StatusViewDelegate {
}
}
// MARK: - MosaicImageViewDelegate
extension StatusTableViewCell: MosaicImageViewContainerDelegate {
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) {
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index)
}
func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) {
delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapContentWarningVisualEffectView: visualEffectView)
}
}
// MARK: - ActionToolbarContainerDelegate
extension StatusTableViewCell: ActionToolbarContainerDelegate {
func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) {