From 124638a0cbe5b34cccc07056a91b562241f728a0 Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Wed, 19 Apr 2023 16:38:58 -0400 Subject: [PATCH] IOS-148 Updates to the media badging look & feel (#1019) --- Mastodon.xcodeproj/project.pbxproj | 8 +- ...ller.swift => AltTextViewController.swift} | 6 +- .../MediaPreviewViewController.swift | 2 +- .../View/Content/ExpandableMediaBadge.swift | 94 +++++++++++++++++++ .../View/Content/MediaAltTextOverlay.swift | 78 --------------- .../MastodonUI/View/Content/MediaBadge.swift | 53 +++++++++++ .../View/Content/MediaBadgesContainer.swift | 68 ++++++++++++++ .../MastodonUI/View/Content/MediaView.swift | 84 ++++------------- 8 files changed, 243 insertions(+), 150 deletions(-) rename Mastodon/Scene/MediaPreview/{AltViewController.swift => AltTextViewController.swift} (95%) create mode 100644 MastodonSDK/Sources/MastodonUI/View/Content/ExpandableMediaBadge.swift delete mode 100644 MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift create mode 100644 MastodonSDK/Sources/MastodonUI/View/Content/MediaBadge.swift create mode 100644 MastodonSDK/Sources/MastodonUI/View/Content/MediaBadgesContainer.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e0b7bb2e9..9baa4a089 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; 85904C01293BC0EB0011C817 /* ImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProvider.swift; sourceTree = ""; }; 85904C03293BC1940011C817 /* URLActivityItemWithMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivityItemWithMetadata.swift; sourceTree = ""; }; - 85BC11B22932414900E191CD /* AltViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltViewController.swift; sourceTree = ""; }; + 85BC11B22932414900E191CD /* AltTextViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltTextViewController.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -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 */, diff --git a/Mastodon/Scene/MediaPreview/AltViewController.swift b/Mastodon/Scene/MediaPreview/AltTextViewController.swift similarity index 95% rename from Mastodon/Scene/MediaPreview/AltViewController.swift rename to Mastodon/Scene/MediaPreview/AltTextViewController.swift index 05a3e5b09..cf8945499 100644 --- a/Mastodon/Scene/MediaPreview/AltViewController.swift +++ b/Mastodon/Scene/MediaPreview/AltTextViewController.swift @@ -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 } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 4dc171e58..d1197ebc8 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -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) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/ExpandableMediaBadge.swift b/MastodonSDK/Sources/MastodonUI/View/Content/ExpandableMediaBadge.swift new file mode 100644 index 000000000..ff272439e --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/ExpandableMediaBadge.swift @@ -0,0 +1,94 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import SwiftUI + +struct ExpandableMediaBadge: 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, 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, 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") + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift deleted file mode 100644 index a625be292..000000000 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaAltTextOverlay.swift +++ /dev/null @@ -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) - } -} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaBadge.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaBadge.swift new file mode 100644 index 000000000..a37bad0e1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaBadge.swift @@ -0,0 +1,53 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import SwiftUI + +struct MediaBadge: 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() + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaBadgesContainer.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaBadgesContainer.swift new file mode 100644 index 000000000..acaddb164 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaBadgesContainer.swift @@ -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) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift index d543e64c8..2a61525e9 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/MediaView.swift @@ -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 = { - let vc = UIHostingController(rootView: MediaAltTextOverlay()) + let badgeViewController: UIHostingController = { + let vc = UIHostingController(rootView: MediaBadgesContainer()) vc.view.backgroundColor = .clear return vc }() @@ -179,14 +162,14 @@ extension MediaView { playerViewController.view.translatesAutoresizingMaskIntoConstraints = false 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() { @@ -295,15 +283,15 @@ extension MediaView { blurhashImageView.removeFromSuperview() 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), - ]) - } - } }