Refactor StatusRowMediaPreviewView (#1654)

* improve the sensitive content overlay animation and refactor subviews

* fix alt text button and refactor views

* refactor `StatusRowMediaPreviewView.onTapGesture`

* simplify `MediaPreview` and `FeaturedImagePreView`

* make alt text button adaptable
This commit is contained in:
Thai D. V 2023-11-07 17:20:35 +07:00 committed by GitHub
parent 9e4b333981
commit 6e1e83cace
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 385 additions and 237 deletions

View file

@ -50,7 +50,7 @@ public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable {
type: "image",
url: url,
previewUrl: url,
description: nil,
description: "demo alt text here",
meta: nil)
}
}

View file

@ -9,11 +9,8 @@ import SwiftUI
@MainActor
public struct StatusRowMediaPreviewView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(\.extraLeadingInset) private var extraLeadingInset: CGFloat
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
@Environment(\.isCompact) private var isCompact: Bool
@Environment(SceneDelegate.self) private var sceneDelegate
@Environment(UserPreferences.self) private var preferences
@Environment(QuickLook.self) private var quickLook
@ -23,9 +20,6 @@ public struct StatusRowMediaPreviewView: View {
public let sensitive: Bool
@State private var isQuickLookLoading: Bool = false
@State private var altTextDisplayed: String?
@State private var isAltAlertDisplayed: Bool = false
@State private var isHidingMedia: Bool = false
var availableWidth: CGFloat {
if UIDevice.current.userInterfaceIdiom == .phone &&
@ -66,39 +60,20 @@ public struct StatusRowMediaPreviewView: View {
return attachments.count > 2 ? 150 : 200
}
private func size(for media: MediaAttachment) -> CGSize? {
if let width = media.meta?.original?.width,
let height = media.meta?.original?.height
{
return .init(width: CGFloat(width), height: CGFloat(height))
}
return nil
}
private func imageSize(from: CGSize, newWidth: CGFloat) -> CGSize {
if isCompact || theme.statusDisplayStyle == .compact || isSecondaryColumn {
return .init(width: imageMaxHeight, height: imageMaxHeight)
}
let ratio = newWidth / from.width
let newHeight = from.height * ratio
return .init(width: newWidth, height: newHeight)
}
public var body: some View {
Group {
if attachments.count == 1, let attachment = attachments.first {
makeFeaturedImagePreview(attachment: attachment)
.onTapGesture {
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.mediaViewer(attachments: attachments,
selectedAttachment: attachment))
} else {
quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
.accessibilityAddTraits([.isButton, .isImage])
if attachments.count == 1 {
FeaturedImagePreView(
attachment: attachments[0],
imageMaxHeight: imageMaxHeight,
sensitive: sensitive,
appLayoutWidth: appLayoutWidth,
availableWidth: availableWidth
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Self.accessibilityLabel(for: attachments[0]))
.accessibilityAddTraits([.isButton, .isImage])
.onTapGesture { tabAction(for: 0) }
} else {
if isCompact || theme.statusDisplayStyle == .compact {
HStack {
@ -121,212 +96,36 @@ public struct StatusRowMediaPreviewView: View {
}
}
}
.overlay {
if isHidingMedia {
sensitiveMediaOverlay
.transition(.opacity)
}
}
.alert("status.editor.media.image-description",
isPresented: $isAltAlertDisplayed)
{
Button("alert.button.ok", action: {})
} message: {
Text(altTextDisplayed ?? "")
}
.onAppear {
if sensitive, preferences.autoExpandMedia == .hideSensitive {
isHidingMedia = true
} else if preferences.autoExpandMedia == .hideAll {
isHidingMedia = true
} else {
isHidingMedia = false
}
}
}
@ViewBuilder
private func makeAttachmentView(for index: Int) -> some View {
if attachments.count > index {
makePreview(attachment: attachments[index])
if
attachments.count > index,
let data = DisplayData(from: attachments[index])
{
MediaPreview(
sensitive: sensitive,
imageMaxHeight: imageMaxHeight,
displayData: data
)
.onTapGesture { tabAction(for: index) }
}
}
@ViewBuilder
private func makeFeaturedImagePreview(attachment: MediaAttachment) -> some View {
ZStack(alignment: .bottomLeading) {
let size: CGSize = size(for: attachment) ?? .init(width: imageMaxHeight, height: imageMaxHeight)
let newSize = imageSize(from: size, newWidth: availableWidth - appLayoutWidth)
switch attachment.supportedType {
case .image:
LazyImage(url: attachment.url) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: newSize.width, height: newSize.height)
.clipped()
.cornerRadius(4)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray)
.frame(width: newSize.width, height: newSize.height)
}
}
.processors([.resize(size: newSize)])
.frame(width: newSize.width, height: newSize.height)
case .gifv, .video, .audio:
if let url = attachment.url {
MediaUIAttachmentVideoView(viewModel: .init(url: url))
.frame(width: newSize.width, height: newSize.height)
}
case .none:
EmptyView()
}
if !isInCaptureMode, sensitive {
cornerSensitiveButton
}
if !isInCaptureMode, let alt = attachment.description, !alt.isEmpty, !isCompact, preferences.showAltTextForMedia {
Group {
Button {
altTextDisplayed = alt
isAltAlertDisplayed = true
} label: {
Text("status.image.alt-text.abbreviation")
.font(theme.statusDisplayStyle == .compact ? .footnote : .body)
}
.buttonStyle(.borderless)
.padding(4)
.background(.thinMaterial)
.cornerRadius(4)
}
.padding(theme.statusDisplayStyle == .compact ? 0 : 10)
}
}
}
@ViewBuilder
private func makePreview(attachment: MediaAttachment) -> some View {
if let type = attachment.supportedType, !isInCaptureMode {
Group {
GeometryReader { proxy in
switch type {
case .image:
ZStack(alignment: .bottomTrailing) {
LazyResizableImage(url: attachment.previewUrl ?? attachment.url) { state, proxy in
let width = isCompact ? imageMaxHeight : proxy.frame(in: .local).width
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: width)
.frame(maxHeight: imageMaxHeight)
.clipped()
.cornerRadius(4)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
} else if state.isLoading {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray)
.frame(maxHeight: imageMaxHeight)
.frame(maxWidth: width)
}
}
if sensitive, !isInCaptureMode {
cornerSensitiveButton
}
if !isInCaptureMode,
let alt = attachment.description,
!alt.isEmpty,
!isCompact,
preferences.showAltTextForMedia
{
Button {
altTextDisplayed = alt
isAltAlertDisplayed = true
} label: {
Text("status.image.alt-text.abbreviation")
.font(.scaledFootnote)
}
.buttonStyle(.borderless)
.padding(4)
.background(.thinMaterial)
.cornerRadius(4)
}
}
case .gifv, .video, .audio:
if let url = attachment.url {
MediaUIAttachmentVideoView(viewModel: .init(url: url))
.frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width)
.frame(height: imageMaxHeight)
.accessibilityAddTraits(.startsMediaSession)
}
}
}
.frame(maxWidth: isCompact ? imageMaxHeight : nil)
.frame(height: imageMaxHeight)
}
// #965: do not create overlapping tappable areas, when multiple images are shown
.contentShape(Rectangle())
.onTapGesture {
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(value: WindowDestination.mediaViewer(attachments: attachments,
selectedAttachment: attachment))
} else {
quickLook.prepareFor(selectedMediaAttachment: attachment, mediaAttachments: attachments)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Self.accessibilityLabel(for: attachment))
.accessibilityAddTraits(attachment.supportedType == .image ? [.isImage, .isButton] : .isButton)
}
}
private var sensitiveMediaOverlay: some View {
ZStack {
Rectangle()
.foregroundColor(.clear)
.background(.ultraThinMaterial)
if !isCompact {
Button {
withAnimation {
isHidingMedia = false
}
} label: {
Group {
if sensitive {
Label("status.media.sensitive.show", systemImage: "eye")
} else {
Label("status.media.content.show", systemImage: "eye")
}
}
.foregroundColor(theme.labelColor)
}
.buttonStyle(.borderedProminent)
}
}
}
private var cornerSensitiveButton: some View {
HStack {
Button {
withAnimation {
isHidingMedia = true
}
} label: {
Image(systemName: "eye.slash")
.frame(minHeight: 21) // Match the alt button in case it is also present
}
.padding(10)
.buttonStyle(.borderedProminent)
Spacer()
private func tabAction(for index: Int) {
if ProcessInfo.processInfo.isMacCatalystApp {
openWindow(
value: WindowDestination.mediaViewer(
attachments: attachments,
selectedAttachment: attachments[index]
)
)
} else {
quickLook.prepareFor(
selectedMediaAttachment: attachments[index],
mediaAttachments: attachments
)
}
}
@ -340,3 +139,352 @@ public struct StatusRowMediaPreviewView: View {
}
}
}
private struct MediaPreview: View {
let sensitive: Bool
let imageMaxHeight: CGFloat
let displayData: DisplayData
@Environment(UserPreferences.self) private var preferences
@Environment(\.isCompact) private var isCompact: Bool
var body: some View {
GeometryReader { proxy in
switch displayData.type {
case .image:
LazyResizableImage(url: displayData.previewUrl) { state, proxy in
let width = isCompact ? imageMaxHeight : proxy.frame(in: .local).width
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: width, maxHeight: imageMaxHeight)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
} else if state.isLoading {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray)
.frame(maxWidth: width, maxHeight: imageMaxHeight)
}
}
.overlay {
BlurOverLay(sensitive: sensitive, font: .scaledFootnote)
}
.overlay {
AltTextButton(text: displayData.description, font: .scaledFootnote)
}
case .av:
MediaUIAttachmentVideoView(viewModel: .init(url: displayData.url))
.frame(width: isCompact ? imageMaxHeight : proxy.frame(in: .local).width)
.frame(height: imageMaxHeight)
.accessibilityAddTraits(.startsMediaSession)
}
}
.frame(maxWidth: isCompact ? imageMaxHeight : nil)
.frame(height: imageMaxHeight)
.clipped()
.cornerRadius(4)
// #965: do not create overlapping tappable areas, when multiple images are shown
.contentShape(Rectangle())
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(displayData.accessibilityText))
.accessibilityAddTraits(displayData.type == .image ? [.isImage, .isButton] : .isButton)
}
}
private struct FeaturedImagePreView: View {
let attachment: MediaAttachment
let imageMaxHeight: CGFloat
let sensitive: Bool
let appLayoutWidth: CGFloat
let availableWidth: CGFloat
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(Theme.self) private var theme
@Environment(\.isCompact) private var isCompact: Bool
var body: some View {
let size: CGSize = size(for: attachment) ?? .init(width: imageMaxHeight, height: imageMaxHeight)
let newSize = imageSize(from: size, newWidth: availableWidth - appLayoutWidth)
Group {
switch attachment.supportedType {
case .image:
LazyImage(url: attachment.url) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 4).fill(Color.gray)
}
}
.processors([.resize(size: newSize)])
case .gifv, .video, .audio:
if let url = attachment.url {
MediaUIAttachmentVideoView(viewModel: .init(url: url))
}
case .none:
EmptyView()
}
}
.frame(width: newSize.width, height: newSize.height)
.overlay {
BlurOverLay(sensitive: sensitive, font: .scaledFootnote)
}
.overlay {
AltTextButton(
text: attachment.description,
font: theme.statusDisplayStyle == .compact ? .footnote : .body
)
}
.clipped()
.cornerRadius(4)
}
private func size(for media: MediaAttachment) -> CGSize? {
guard let width = media.meta?.original?.width,
let height = media.meta?.original?.height
else { return nil }
return .init(width: CGFloat(width), height: CGFloat(height))
}
private func imageSize(from: CGSize, newWidth: CGFloat) -> CGSize {
if isCompact || theme.statusDisplayStyle == .compact || isSecondaryColumn {
return .init(width: imageMaxHeight, height: imageMaxHeight)
}
let ratio = newWidth / from.width
let newHeight = from.height * ratio
return .init(width: newWidth, height: newHeight)
}
}
@MainActor
struct BlurOverLay: View {
let sensitive: Bool
let font: Font?
@State private var isFrameExpanded = true
@State private var isTextExpanded = true
@Environment(Theme.self) private var theme
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
@Environment(UserPreferences.self) private var preferences
@Environment(\.isCompact) private var isCompact: Bool
@Namespace var buttonSpace
var body: some View {
if hasOverlay {
ZStack {
Rectangle()
.foregroundColor(.clear)
.background(.ultraThinMaterial)
.frame(
width: isFrameExpanded ? nil : 0,
height: isFrameExpanded ? nil : 0)
if !isCompact {
Button {
withAnimation(.spring(duration: 0.2)) {
isTextExpanded.toggle()
} completion: {
withAnimation(.spring(duration: 0.3)) {
isFrameExpanded.toggle()
}
}
} label: {
if isTextExpanded {
ViewThatFits(in: .horizontal) {
HStack {
Image(systemName: "eye")
Text(sensitive ? "status.media.sensitive.show" : "status.media.content.show" )
}
HStack {
Image(systemName: "eye")
Text("Show")
}
Image(systemName: "eye")
}
.lineLimit(1)
.foregroundColor(theme.labelColor)
.matchedGeometryEffect(id: "text", in: buttonSpace)
} else {
Image(systemName: "eye.slash")
.matchedGeometryEffect(id: "text", in: buttonSpace)
}
}
.foregroundColor(theme.labelColor)
.buttonStyle(.borderedProminent)
.padding(theme.statusDisplayStyle == .compact ? 0 : 10)
}
}
.font(font)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: isFrameExpanded ? .center : .bottomLeading
)
} else {
EmptyView()
}
}
private var hasOverlay: Bool {
switch (sensitive, preferences.autoExpandMedia) {
case (_, .hideAll), (true, .hideSensitive):
switch isInCaptureMode {
case true: false
case false: true
}
default: false
}
}
}
struct AltTextButton: View {
let text: String?
let font: Font?
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
@Environment(\.isCompact) private var isCompact: Bool
@Environment(UserPreferences.self) private var preferences
@Environment(\.locale) private var locale
@Environment(Theme.self) private var theme
@State private var isDisplayingAlert = false
var body: some View {
if !isInCaptureMode,
let text = text,
!text.isEmpty,
!isCompact,
preferences.showAltTextForMedia
{
Button {
isDisplayingAlert = true
} label: {
ZStack {
// use to sync button with show/hide content button
Image(systemName: "eye.slash").opacity(0)
Text("status.image.alt-text.abbreviation")
}
}
.buttonStyle(.borderless)
.padding(EdgeInsets(top: 5, leading: 7, bottom: 5, trailing: 7))
.background(.thinMaterial)
.cornerRadius(4)
.padding(theme.statusDisplayStyle == .compact ? 0 : 10)
.alert(
"status.editor.media.image-description",
isPresented: $isDisplayingAlert
) {
Button("alert.button.ok", action: {})
} message: {
Text(text)
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .bottomTrailing
)
}
}
}
private struct DisplayData: Identifiable, Hashable {
let id: String
let url: URL
let previewUrl: URL?
let description: String?
let type: DisplayType
let accessibilityText: String
init?(from attachment: MediaAttachment) {
guard let url = attachment.url else { return nil }
guard let type = attachment.supportedType else { return nil }
id = attachment.id
self.url = url
self.previewUrl = attachment.previewUrl ?? attachment.url
description = attachment.description
self.type = DisplayType(from: type)
accessibilityText = Self.getAccessibilityString(from: attachment)
}
private static func getAccessibilityString(from attachment: MediaAttachment) -> String {
if let altText = attachment.description {
"accessibility.image.alt-text-\(altText)"
} else if let typeDescription = attachment.localizedTypeDescription {
typeDescription
} else {
"accessibility.tabs.profile.picker.media"
}
}
}
private enum DisplayType {
case image
case av
init(from attachmentType: MediaAttachment.SupportedType) {
switch attachmentType {
case .image:
self = .image
case .video, .gifv, .audio:
self = .av
}
}
}
struct StatusRowMediaPreviewView_Previews: PreviewProvider {
static var previews: some View {
WrapperForPreview()
}
}
struct WrapperForPreview: View {
@State private var isCompact = false
@State private var isInCaptureMode = false
var body: some View {
VStack {
ScrollView {
VStack {
ForEach(1..<5) { number in
VStack {
Text("Preview for \(number) item(s)")
StatusRowMediaPreviewView(
attachments: Array(repeating: Self.attachment, count: number),
sensitive: true
)
}
.padding()
.border(.red)
}
}
}
.environment(SceneDelegate())
.environment(UserPreferences.shared)
.environment(QuickLook.shared)
.environment(Theme.shared)
.environment(\.isCompact, isCompact)
.environment(\.isInCaptureMode, isInCaptureMode)
Divider()
Toggle("Compact Mode", isOn: $isCompact.animation())
Toggle("Capture Mode", isOn: $isInCaptureMode)
}
.padding()
}
static private let url = URL(string: "https://www.upwork.com/catalog-images/c5dffd9b5094556adb26e0a193a1c494")!
static private let attachment = MediaAttachment.imageWith(url: url)
static private let local = Locale(identifier: "en")
}