IOS-148 Updates to the media badging look & feel (#1019)

This commit is contained in:
Jed Fox 2023-04-19 16:38:58 -04:00 committed by GitHub
parent 391bc455ea
commit 124638a0cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 243 additions and 150 deletions

View File

@ -130,7 +130,7 @@
855149CA29606D6400943D96 /* PortraitAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855149C929606D6400943D96 /* PortraitAlertController.swift */; };
85904C02293BC0EB0011C817 /* ImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C01293BC0EB0011C817 /* ImageProvider.swift */; };
85904C04293BC1940011C817 /* URLActivityItemWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */; };
85BC11B32932414900E191CD /* AltViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BC11B22932414900E191CD /* AltViewController.swift */; };
85BC11B32932414900E191CD /* AltTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BC11B22932414900E191CD /* AltTextViewController.swift */; };
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; };
9E44C7202967AD17004B2A72 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 9E44C71F2967AD17004B2A72 /* MastodonSDKDynamic */; };
9E44C7222967AD17004B2A72 /* MastodonSDKDynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 9E44C71F2967AD17004B2A72 /* MastodonSDKDynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
@ -755,7 +755,7 @@
855149C929606D6400943D96 /* PortraitAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitAlertController.swift; sourceTree = "<group>"; };
85904C01293BC0EB0011C817 /* ImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProvider.swift; sourceTree = "<group>"; };
85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivityItemWithMetadata.swift; sourceTree = "<group>"; };
85BC11B22932414900E191CD /* AltViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltViewController.swift; sourceTree = "<group>"; };
85BC11B22932414900E191CD /* AltTextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltTextViewController.swift; sourceTree = "<group>"; };
8850E70A1D5FF51432E43653 /* Pods-Mastodon-MastodonUITests.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - release.xcconfig"; sourceTree = "<group>"; };
8E79CCBE51FBC3F7FE8CF49F /* Pods-MastodonTests.release snapshot.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release snapshot.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release snapshot.xcconfig"; sourceTree = "<group>"; };
8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
@ -2242,7 +2242,7 @@
DB6180F026391CAB0018D199 /* Image */,
DB6180E1263919780018D199 /* Paging */,
DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */,
85BC11B22932414900E191CD /* AltViewController.swift */,
85BC11B22932414900E191CD /* AltTextViewController.swift */,
DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */,
);
path = MediaPreview;
@ -3900,7 +3900,7 @@
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */,
DB3EA8F1281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift in Sources */,
85BC11B32932414900E191CD /* AltViewController.swift in Sources */,
85BC11B32932414900E191CD /* AltTextViewController.swift in Sources */,
DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */,
DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */,
DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */,

View File

@ -1,5 +1,5 @@
//
// AltViewController.swift
// AltTextViewController.swift
// Mastodon
//
// Created by Jed Fox on 2022-11-26.
@ -7,7 +7,7 @@
import SwiftUI
class AltViewController: UIViewController {
class AltTextViewController: UIViewController {
let textView = {
let textView: UITextView
@ -85,7 +85,7 @@ class AltViewController: UIViewController {
}
// MARK: UIPopoverPresentationControllerDelegate
extension AltViewController: UIPopoverPresentationControllerDelegate {
extension AltTextViewController: UIPopoverPresentationControllerDelegate {
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
.none
}

View File

@ -186,7 +186,7 @@ extension MediaPreviewViewController {
@objc private func altButtonPressed(_ sender: UIButton) {
guard let alt = viewModel.altText else { return }
present(AltViewController(alt: alt, sourceView: sender), animated: true)
present(AltTextViewController(alt: alt, sourceView: sender), animated: true)
}
}

View File

@ -0,0 +1,94 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import SwiftUI
struct ExpandableMediaBadge<Label: View, Content: View>: View {
@Binding private var isExpanded: Bool
private let parentGeometry: (size: CGSize, space: AnyHashable)
private let label: Label
private let content: Content
@Namespace private var namespace
init(isExpanded: Binding<Bool>, in parentGeometry: (CGSize, AnyHashable), @ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) {
self._isExpanded = isExpanded
self.parentGeometry = parentGeometry
self.content = content()
self.label = label()
}
var body: some View {
MediaBadge {
label
}
.opacity(0)
.overlay {
GeometryReader { geom in
Color.clear
.preference(key: OffsetRect.self, value: geom.frame(in: .named(parentGeometry.space)))
}
}
.overlayPreferenceValue(OffsetRect.self, alignment: .bottomLeading) { offsetRect in
MediaBadge {
HStack {
if isExpanded {
content
.font(.caption)
.matchedGeometryEffect(id: "background", in: namespace, properties: .position)
.transition(
.scale(scale: 0.2, anchor: .bottomLeading)
.combined(with: .opacity)
)
.layoutPriority(1)
Spacer(minLength: 0)
} else {
label
.matchedGeometryEffect(id: "background", in: namespace, properties: .position)
.transition(
.scale(scale: 3, anchor: .trailing)
.combined(with: .opacity)
)
}
}
.padding(.vertical, isExpanded ? (8 - 2) : 0)
}
.frame(width: isExpanded ? parentGeometry.size.width : nil)
.offset(x: isExpanded ? -offsetRect.minX : 0)
.animation(.spring(response: 0.3), value: isExpanded)
// this is not accessible, but the badge UI is not shown to accessibility tools at the moment
.onTapGesture {
isExpanded.toggle()
}
}
// necessary to keep the expanded state from underlapping the collapsed badges
// NOTE: if you want multiple expandable badges you will need to change this somehow. Good luck!
.zIndex(1)
}
}
extension ExpandableMediaBadge where Label == Text {
init(_ label: String, isExpanded: Binding<Bool>, in parentGeometry: (CGSize, AnyHashable), @ViewBuilder content: () -> Content) {
self.init(isExpanded: isExpanded, in: parentGeometry, content: content) {
Text(label)
}
}
}
private struct OffsetRect: PreferenceKey {
static var defaultValue = CGRect.zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct ExpandableMediaBadge_Previews: PreviewProvider {
static var previews: some View {
GeometryReader { geom in
ExpandableMediaBadge(isExpanded: .constant(false), in: (geom.size, "preview")) {
Text("Hello world!")
} label: {
Text("ALT")
}
}.coordinateSpace(name: "preview")
}
}

View File

@ -1,78 +0,0 @@
//
// MediaAltTextOverlay.swift
//
//
// Created by Jed Fox on 2022-12-20.
//
import SwiftUI
struct MediaAltTextOverlay: View {
var altDescription: String?
@State private var showingAlt = false
@Namespace private var namespace
var body: some View {
GeometryReader { geom in
ZStack {
if let altDescription {
if showingAlt {
HStack(alignment: .top) {
Text(altDescription)
Spacer()
Button(action: { showingAlt = false }) {
Image(systemName: "xmark.circle.fill")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 20, height: 20)
}
}
.padding(8)
.matchedGeometryEffect(id: "background", in: namespace, properties: .position)
.transition(
.scale(scale: 0.2, anchor: .bottomLeading)
.combined(with: .opacity)
)
} else {
Button("ALT") { showingAlt = true }
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.matchedGeometryEffect(id: "background", in: namespace, properties: .position)
.transition(
.scale(scale: 3, anchor: .trailing)
.combined(with: .opacity)
)
}
}
}
.foregroundColor(.white)
.tint(.white)
.background(Color.black.opacity(0.85))
.cornerRadius(4)
.overlay(
.white.opacity(0.5),
in: RoundedRectangle(cornerRadius: 4)
.inset(by: -0.5)
.stroke(lineWidth: 0.5)
)
.animation(.spring(response: 0.3), value: showingAlt)
.frame(width: geom.size.width, height: geom.size.height, alignment: .bottomLeading)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.onChange(of: altDescription) { _ in
showingAlt = false
}
}
}
struct MediaAltTextOverlay_Previews: PreviewProvider {
static var previews: some View {
MediaAltTextOverlay(altDescription: "Hello, world!")
.frame(height: 300)
.background(Color.gray)
.previewLayout(.sizeThatFits)
}
}

View File

@ -0,0 +1,53 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import SwiftUI
struct MediaBadge<Content: View>: View {
private let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
// need the VStack (or some other kind of containing view) to
// ensure the transition animations work properly
// Is this a bug? Is it intended behavior? I have no clue
HStack {
content
}
.font(.subheadline.bold())
.padding(.horizontal, 8)
.padding(.vertical, 2)
.foregroundColor(.white)
.tint(.white)
.background(Color.black.opacity(0.7))
.cornerRadius(3)
.accessibilityHidden(true)
}
}
extension MediaBadge where Content == Text {
init(_ text: String) {
self.init {
Text(text)
}
}
}
struct MediaBadge_Previews: PreviewProvider {
static var previews: some View {
MediaBadge {
Button("ALT") {}
}
MediaBadge {
Button("GIF") {}
}
MediaBadge {
Text("01:24")
.monospacedDigit()
}
}
}

View File

@ -0,0 +1,68 @@
//
// MediaBadgesContainer.swift
//
// Created by Jed Fox on 2022-12-20.
//
import SwiftUI
struct MediaBadgesContainer: View {
var altDescription: String?
var isGIF = false
var showDuration = false
var mediaDuration: TimeInterval?
@State private var showingAlt = false
@State private var space = AnyHashable(UUID())
// Date.ComponentsFormatStyle does not allow force-enabling minutes unit
static let formatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = []
formatter.formattingContext = .standalone
return formatter
}()
var body: some View {
GeometryReader { geom in
HStack(alignment: .bottom, spacing: 2) {
if let altDescription {
ExpandableMediaBadge("ALT", isExpanded: $showingAlt, in: (geom.size, space)) {
Text(altDescription)
.frame(maxHeight: geom.size.height - 16)
.fixedSize(horizontal: false, vertical: true)
}
}
if isGIF {
MediaBadge("GIF")
}
if showDuration {
if let mediaDuration, let format = Self.formatter.string(from: mediaDuration) {
MediaBadge(format)
.monospacedDigit()
} else {
MediaBadge("--:--")
}
}
}
.frame(width: geom.size.width, height: geom.size.height, alignment: .bottomLeading)
.coordinateSpace(name: space)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.onChange(of: altDescription) { _ in
showingAlt = false
}
}
}
struct MediaAltTextOverlay_Previews: PreviewProvider {
static var previews: some View {
MediaBadgesContainer(altDescription: "Hello, world!")
.frame(height: 300)
.background(Color.gray)
.previewLayout(.sizeThatFits)
}
}

View File

@ -66,25 +66,8 @@ public final class MediaView: UIView {
return wrapper
}()
private(set) lazy var indicatorBlurEffectView: UIVisualEffectView = {
let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
effectView.layer.masksToBounds = true
effectView.layer.cornerCurve = .continuous
effectView.layer.cornerRadius = 4
return effectView
}()
private(set) lazy var indicatorVibrancyEffectView = UIVisualEffectView(
effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))
)
private(set) lazy var playerIndicatorLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabel
return label
}()
let altViewController: UIHostingController<MediaAltTextOverlay> = {
let vc = UIHostingController(rootView: MediaAltTextOverlay())
let badgeViewController: UIHostingController<MediaBadgesContainer> = {
let vc = UIHostingController(rootView: MediaBadgesContainer())
vc.view.backgroundColor = .clear
return vc
}()
@ -180,13 +163,13 @@ extension MediaView {
container.addSubview(playerViewController.view)
playerViewController.view.pinToParent()
setupIndicatorViewHierarchy()
playerIndicatorLabel.attributedText = NSAttributedString(string: "GIF")
layoutAlt()
}
private func bindGIF(configuration: Configuration, info: Configuration.VideoInfo) {
badgeViewController.rootView.mediaDuration = info.durationMS.map { Double($0) / 1000 }
badgeViewController.rootView.showDuration = false
guard let player = setupGIFPlayer(info: info) else { return }
setupPlayerLooper(player: player)
playerViewController.player = player
@ -195,6 +178,8 @@ extension MediaView {
// auto play for GIF
player.play()
badgeViewController.rootView.isGIF = true
bindAlt(configuration: configuration, altDescription: info.altDescription)
}
@ -212,6 +197,9 @@ extension MediaView {
}
private func bindVideo(configuration: Configuration, info: Configuration.VideoInfo) {
badgeViewController.rootView.mediaDuration = info.durationMS.map { Double($0) / 1000 }
badgeViewController.rootView.showDuration = true
let imageInfo = Configuration.ImageInfo(
aspectRadio: info.aspectRadio,
assetURL: info.previewURL,
@ -231,7 +219,7 @@ extension MediaView {
accessibilityLabel = altDescription
}
altViewController.rootView.altDescription = altDescription
badgeViewController.rootView.altDescription = altDescription
}
private func layoutBlurhash() {
@ -263,9 +251,9 @@ extension MediaView {
}
private func layoutAlt() {
altViewController.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(altViewController.view)
altViewController.view.pinToParent()
badgeViewController.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(badgeViewController.view)
badgeViewController.view.pinToParent()
}
public func prepareForReuse() {
@ -296,14 +284,14 @@ extension MediaView {
blurhashImageView.removeConstraints(blurhashImageView.constraints)
blurhashImageView.image = nil
// reset indicator
indicatorBlurEffectView.removeFromSuperview()
// reset container
container.removeFromSuperview()
container.removeConstraints(container.constraints)
altViewController.rootView.altDescription = nil
badgeViewController.rootView.altDescription = nil
badgeViewController.rootView.isGIF = false
badgeViewController.rootView.showDuration = false
badgeViewController.rootView.mediaDuration = nil
// reset configuration
configuration = nil
@ -333,36 +321,4 @@ extension MediaView {
addSubview(container)
container.pinToParent()
}
private func setupIndicatorViewHierarchy() {
let blurEffectView = indicatorBlurEffectView
let vibrancyEffectView = indicatorVibrancyEffectView
assert(playerViewController.contentOverlayView != nil)
if let contentOverlayView = playerViewController.contentOverlayView {
blurEffectView.translatesAutoresizingMaskIntoConstraints = false
contentOverlayView.addSubview(indicatorBlurEffectView)
NSLayoutConstraint.activate([
contentOverlayView.trailingAnchor.constraint(equalTo: blurEffectView.trailingAnchor, constant: 16),
contentOverlayView.bottomAnchor.constraint(equalTo: blurEffectView.bottomAnchor, constant: 8),
])
}
if vibrancyEffectView.superview == nil {
vibrancyEffectView.translatesAutoresizingMaskIntoConstraints = false
blurEffectView.contentView.addSubview(vibrancyEffectView)
vibrancyEffectView.pinToParent()
}
if playerIndicatorLabel.superview == nil {
playerIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false
vibrancyEffectView.contentView.addSubview(playerIndicatorLabel)
NSLayoutConstraint.activate([
playerIndicatorLabel.topAnchor.constraint(equalTo: vibrancyEffectView.contentView.topAnchor),
playerIndicatorLabel.leadingAnchor.constraint(equalTo: vibrancyEffectView.contentView.leadingAnchor, constant: 3),
vibrancyEffectView.contentView.trailingAnchor.constraint(equalTo: playerIndicatorLabel.trailingAnchor, constant: 3),
playerIndicatorLabel.bottomAnchor.constraint(equalTo: vibrancyEffectView.contentView.bottomAnchor),
])
}
}
}