IceCubesApp/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowMediaPreviewView.swift
tkgka 3782300b27
Fix StatusRowContentView invade SwipeActions area (#2007)
* Fix StatusRowContentView invade SwipeActions area

* ./ add padding inside StatusRowMediaPreviewView
2024-04-17 10:54:46 -07:00

533 lines
16 KiB
Swift

import DesignSystem
import Env
import MediaUI
import Models
import Nuke
import NukeUI
import SwiftUI
@MainActor
public struct StatusRowMediaPreviewView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.isMediaCompact) private var isCompact: Bool
@Environment(QuickLook.self) private var quickLook
@Environment(Theme.self) private var theme
public let attachments: [MediaAttachment]
public let sensitive: Bool
@State private var isQuickLookLoading: Bool = false
init(attachments: [MediaAttachment], sensitive: Bool) {
self.attachments = attachments
self.sensitive = sensitive
}
#if targetEnvironment(macCatalyst)
private var showsScrollIndicators: Bool { attachments.count > 1 }
private var scrollBottomPadding: CGFloat?
#else
private var showsScrollIndicators: Bool = false
private var scrollBottomPadding: CGFloat? = 0
#endif
private var imageMaxHeight: CGFloat {
if isCompact {
return 50
}
if theme.statusDisplayStyle == .compact {
if attachments.count == 1 {
return 200
}
return 100
}
return 300
}
public var body: some View {
Group {
if attachments.count == 1 {
FeaturedImagePreView(
attachment: attachments[0],
maxSize: imageMaxHeight == 300
? nil
: CGSize(width: imageMaxHeight, height: imageMaxHeight),
sensitive: sensitive
)
.padding(.horizontal, .layoutPadding)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Self.accessibilityLabel(for: attachments[0]))
.accessibilityAddTraits([.isButton, .isImage])
.onTapGesture { tabAction(for: 0) }
} else {
ScrollView(.horizontal, showsIndicators: showsScrollIndicators) {
HStack {
ForEach(attachments) { attachment in
makeAttachmentView(attachment)
}
}
.padding(.bottom, scrollBottomPadding)
.padding(.horizontal, .layoutPadding)
}
}
}
.padding(.horizontal, -1 * .layoutPadding)
}
@ViewBuilder
private func makeAttachmentView(_ attachement: MediaAttachment) -> some View {
if let data = DisplayData(from: attachement) {
MediaPreview(
sensitive: sensitive,
imageMaxHeight: imageMaxHeight,
displayData: data
)
.onTapGesture {
if let index = attachments.firstIndex(where: { $0.id == attachement.id }) {
tabAction(for: index)
}
}
#if os(visionOS)
.hoverEffect()
#endif
}
}
private func tabAction(for index: Int) {
#if targetEnvironment(macCatalyst) || os(visionOS)
openWindow(
value: WindowDestinationMedia.mediaViewer(
attachments: attachments,
selectedAttachment: attachments[index]
)
)
#else
quickLook.prepareFor(
selectedMediaAttachment: attachments[index],
mediaAttachments: attachments
)
#endif
}
private static func accessibilityLabel(for attachment: MediaAttachment) -> Text {
if let altText = attachment.description {
Text("accessibility.image.alt-text-\(altText)")
} else if let typeDescription = attachment.localizedTypeDescription {
Text(typeDescription)
} else {
Text("accessibility.tabs.profile.picker.media")
}
}
}
private struct MediaPreview: View {
let sensitive: Bool
let imageMaxHeight: CGFloat
let displayData: DisplayData
var body: some View {
Group {
switch displayData.type {
case .image:
LazyResizableImage(url: displayData.previewUrl) { state, _ in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: displayData.isLandscape ? imageMaxHeight * 1.2 : imageMaxHeight / 1.5,
height: imageMaxHeight)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
} else if state.isLoading {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray)
}
}
.overlay {
BlurOverLay(sensitive: sensitive, font: .scaledFootnote)
}
.overlay {
AltTextButton(text: displayData.description, font: .scaledFootnote)
}
case .av:
MediaUIAttachmentVideoView(viewModel: .init(url: displayData.url))
.accessibilityAddTraits(.startsMediaSession)
}
}
.frame(width: displayData.isLandscape ? imageMaxHeight * 1.2 : imageMaxHeight / 1.5,
height: imageMaxHeight)
.clipped()
.cornerRadius(10)
// #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)
}
}
@MainActor
struct BlurOverLay: View {
let sensitive: Bool
let font: Font?
@State private var isFrameExpanded = true
@Environment(Theme.self) private var theme
@Environment(\.isInCaptureMode) private var isInCaptureMode: Bool
@Environment(UserPreferences.self) private var preferences
@Environment(\.isMediaCompact) 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) {
isFrameExpanded.toggle()
}
} label: {
if isFrameExpanded {
ViewThatFits(in: .horizontal) {
HStack {
Image(systemName: "eye")
.matchedGeometryEffect(id: "eye", in: buttonSpace)
Text(sensitive ? "status.media.sensitive.show" : "status.media.content.show")
}
HStack {
Image(systemName: "eye")
.matchedGeometryEffect(id: "eye", in: buttonSpace)
Text("Show")
}
Image(systemName: "eye")
.matchedGeometryEffect(id: "eye", in: buttonSpace)
}
.lineLimit(1)
.foregroundColor(theme.contrastingTintColor)
} else {
Image(systemName: "eye.slash")
.transition(.opacity)
.matchedGeometryEffect(id: "eye", 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(\.isMediaCompact) 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.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)
#if os(visionOS)
.clipShape(Capsule())
#endif
.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
let isLandscape: Bool
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
previewUrl = attachment.previewUrl ?? attachment.url
description = attachment.description
self.type = DisplayType(from: type)
accessibilityText = Self.getAccessibilityString(from: attachment)
isLandscape = (attachment.meta?.original?.width ?? 0) > (attachment.meta?.original?.height ?? 0)
}
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(\.isMediaCompact, isCompact)
.environment(\.isInCaptureMode, isInCaptureMode)
Divider()
Toggle("Compact Mode", isOn: $isCompact.animation())
Toggle("Capture Mode", isOn: $isInCaptureMode)
}
.padding()
}
private static let url = URL(string: "https://www.upwork.com/catalog-images/c5dffd9b5094556adb26e0a193a1c494")!
private static let attachment = MediaAttachment.imageWith(url: url)
private static let local = Locale(identifier: "en")
}
@MainActor
private struct FeaturedImagePreView: View {
let attachment: MediaAttachment
let maxSize: CGSize?
let sensitive: Bool
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(Theme.self) private var theme
@Environment(\.isModal) private var isModal: Bool
private var originalWidth: CGFloat {
CGFloat(attachment.meta?.original?.width ?? 300)
}
private var originalHeight: CGFloat {
CGFloat(attachment.meta?.original?.height ?? 300)
}
var body: some View {
if let url = attachment.url {
_Layout(originalWidth: originalWidth, originalHeight: originalHeight, maxSize: maxSize) {
Group {
RoundedRectangle(cornerRadius: 10).fill(Color.gray)
.overlay {
switch attachment.supportedType {
case .image:
LazyResizableImage(url: attachment.url) { state, _ in
if let image = state.image {
image
.resizable()
.scaledToFill()
} else {
RoundedRectangle(cornerRadius: 10).fill(Color.gray)
}
}
case .gifv, .video, .audio:
MediaUIAttachmentVideoView(viewModel: .init(url: url))
default:
EmptyView()
}
}
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.gray.opacity(0.35), lineWidth: 1)
)
#if os(visionOS)
.hoverEffect()
#endif
}
}
.overlay {
BlurOverLay(sensitive: sensitive, font: .scaledFootnote)
}
.overlay {
AltTextButton(
text: attachment.description,
font: theme.statusDisplayStyle == .compact ? .footnote : .body
)
}
.clipped()
.cornerRadius(10)
}
}
private struct _Layout: Layout {
let originalWidth: CGFloat
let originalHeight: CGFloat
let maxSize: CGSize?
init(originalWidth: CGFloat?, originalHeight: CGFloat?, maxSize: CGSize?) {
self.originalWidth = originalWidth ?? 200
self.originalHeight = originalHeight ?? 200
self.maxSize = maxSize
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
guard !subviews.isEmpty else { return CGSize.zero }
if let maxSize { return maxSize }
return calculateSize(proposal)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) {
guard let view = subviews.first else { return }
let size = if let maxSize { maxSize } else { calculateSize(proposal) }
view.place(at: bounds.origin, proposal: ProposedViewSize(size))
}
private func calculateSize(_ proposal: ProposedViewSize) -> CGSize {
var size: CGSize
switch (proposal.width, proposal.height) {
case (0, _), (_, 0):
size = CGSize.zero
case (nil, nil), (nil, .some(.infinity)), (.some(.infinity), .some(.infinity)), (.some(.infinity), nil):
size = CGSize(width: originalWidth, height: originalWidth)
case let (nil, .some(height)), let (.some(.infinity), .some(height)):
let minHeight = min(height, originalWidth)
if originalHeight == 0 {
size = CGSize.zero
} else {
size = CGSize(width: originalWidth * minHeight / originalHeight, height: minHeight)
}
case let (.some(width), .some(.infinity)), let (.some(width), nil):
if originalWidth == 0 {
size = CGSize(width: width, height: width)
} else {
size = CGSize(width: width, height: width / originalWidth * originalHeight)
}
case let (.some(width), .some(height)):
// intrinsic size of image fits just fine
if originalWidth <= width, originalHeight <= height {
size = CGSize(width: originalWidth, height: originalHeight)
}
// shrink image proportionally to fit inside the box
let xRatio = width / originalWidth
let yRatio = height / originalHeight
// use small ratio to fit the image in
if xRatio < yRatio {
size = CGSize(width: width, height: originalHeight * xRatio)
} else {
size = CGSize(width: originalWidth * yRatio, height: height)
}
}
return CGSize(width: max(size.width, 200), height: min(size.height, 450))
}
}
}