mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
61
Yattee/Extensions/PlatformImage+Cropping.swift
Normal file
61
Yattee/Extensions/PlatformImage+Cropping.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// PlatformImage+Cropping.swift
|
||||
// Yattee
|
||||
//
|
||||
// Cross-platform image cropping for extracting thumbnails from sprite sheets.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
/// Crops the image to the specified rect.
|
||||
/// - Parameter rect: The rect to crop in image coordinates (origin at top-left)
|
||||
/// - Returns: The cropped image, or nil if cropping fails
|
||||
func cropped(to rect: CGRect) -> NSImage? {
|
||||
// Get CGImage representation
|
||||
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NSImage coordinates have origin at bottom-left, but CGImage has origin at top-left
|
||||
// The rect is already in top-left coordinate system, so use it directly
|
||||
guard let croppedCGImage = cgImage.cropping(to: rect) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSImage(cgImage: croppedCGImage, size: rect.size)
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
/// Crops the image to the specified rect.
|
||||
/// - Parameter rect: The rect to crop in image coordinates (origin at top-left)
|
||||
/// - Returns: The cropped image, or nil if cropping fails
|
||||
func cropped(to rect: CGRect) -> UIImage? {
|
||||
guard let cgImage = self.cgImage else { return nil }
|
||||
|
||||
// Scale rect for image scale (Retina displays)
|
||||
let scale = self.scale
|
||||
let scaledRect = CGRect(
|
||||
x: rect.origin.x * scale,
|
||||
y: rect.origin.y * scale,
|
||||
width: rect.width * scale,
|
||||
height: rect.height * scale
|
||||
)
|
||||
|
||||
guard let croppedCGImage = cgImage.cropping(to: scaledRect) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UIImage(cgImage: croppedCGImage, scale: scale, orientation: .up)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
14
Yattee/Extensions/PlatformImage.swift
Normal file
14
Yattee/Extensions/PlatformImage.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// PlatformImage.swift
|
||||
// Yattee
|
||||
//
|
||||
// Cross-platform image typealias.
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
typealias PlatformImage = NSImage
|
||||
#else
|
||||
import UIKit
|
||||
typealias PlatformImage = UIImage
|
||||
#endif
|
||||
18
Yattee/Extensions/Video+DeArrow.swift
Normal file
18
Yattee/Extensions/Video+DeArrow.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Video+DeArrow.swift
|
||||
// Yattee
|
||||
//
|
||||
// Extension for DeArrow title resolution on Video.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Video {
|
||||
/// Returns the DeArrow-replaced title if available, otherwise the original title.
|
||||
///
|
||||
/// Usage: `video.displayTitle(using: appEnvironment?.deArrowBrandingProvider)`
|
||||
@MainActor
|
||||
func displayTitle(using provider: DeArrowBrandingProvider?) -> String {
|
||||
provider?.title(for: self) ?? title
|
||||
}
|
||||
}
|
||||
50
Yattee/Extensions/View+Conditional.swift
Normal file
50
Yattee/Extensions/View+Conditional.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// View+Conditional.swift
|
||||
// Yattee
|
||||
//
|
||||
// Conditional view modifier extension.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
/// Conditionally applies a transformation to the view.
|
||||
@ViewBuilder
|
||||
func `if`<Transform: View>(
|
||||
_ condition: Bool,
|
||||
transform: (Self) -> Transform
|
||||
) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditionally applies one of two transformations.
|
||||
@ViewBuilder
|
||||
func `if`<TrueContent: View, FalseContent: View>(
|
||||
_ condition: Bool,
|
||||
then trueTransform: (Self) -> TrueContent,
|
||||
else falseTransform: (Self) -> FalseContent
|
||||
) -> some View {
|
||||
if condition {
|
||||
trueTransform(self)
|
||||
} else {
|
||||
falseTransform(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditionally applies a transformation when an optional value is present.
|
||||
@ViewBuilder
|
||||
func ifLet<T, Transform: View>(
|
||||
_ value: T?,
|
||||
transform: (Self, T) -> Transform
|
||||
) -> some View {
|
||||
if let value {
|
||||
transform(self, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Yattee/Extensions/View+LiquidGlassSheet.swift
Normal file
150
Yattee/Extensions/View+LiquidGlassSheet.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// View+LiquidGlassSheet.swift
|
||||
// Yattee
|
||||
//
|
||||
// View modifiers for iOS 26 Liquid Glass morphing sheet transitions.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Transition Source Modifier (for Views)
|
||||
|
||||
/// View modifier that marks a view as the source for a morphing sheet transition.
|
||||
/// Apply this to a Button inside a ToolbarItem to enable the morphing effect on iOS 26+.
|
||||
struct LiquidGlassTransitionSourceModifier: ViewModifier {
|
||||
let id: String
|
||||
let namespace: Namespace.ID
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.matchedTransitionSource(id: id, in: namespace)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheet Content Modifier (for sheet content)
|
||||
|
||||
/// View modifier that applies the zoom navigation transition to sheet content.
|
||||
/// Apply this to the content inside a .sheet() to complete the morphing effect on iOS 26+.
|
||||
struct LiquidGlassSheetContentModifier: ViewModifier {
|
||||
let sourceID: String
|
||||
let namespace: Namespace.ID
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.navigationTransition(.zoom(sourceID: sourceID, in: namespace))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extensions
|
||||
|
||||
extension View {
|
||||
/// Marks this view as the source for a Liquid Glass morphing sheet transition.
|
||||
///
|
||||
/// Apply this modifier to a Button (or other view) that presents a sheet. On iOS 26+,
|
||||
/// the sheet will morph from this view when presented.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: Unique identifier for the transition (must match the sheet content's sourceID).
|
||||
/// - namespace: A namespace for the matched geometry effect.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// @Namespace private var sheetTransition
|
||||
///
|
||||
/// .toolbar {
|
||||
/// ToolbarItem(placement: .primaryAction) {
|
||||
/// Button { showSheet = true } label: {
|
||||
/// Image(systemName: "gear")
|
||||
/// }
|
||||
/// .liquidGlassTransitionSource(id: "settings", in: sheetTransition)
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
func liquidGlassTransitionSource(
|
||||
id: String,
|
||||
in namespace: Namespace.ID
|
||||
) -> some View {
|
||||
modifier(LiquidGlassTransitionSourceModifier(id: id, namespace: namespace))
|
||||
}
|
||||
|
||||
/// Applies the Liquid Glass morphing transition to sheet content.
|
||||
///
|
||||
/// Apply this modifier to the content inside a `.sheet()` modifier. On iOS 26+,
|
||||
/// the sheet will morph from the matched transition source when presented.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sourceID: Unique identifier matching the transition source's id.
|
||||
/// - namespace: A namespace for the matched geometry effect (must match the source).
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// .sheet(isPresented: $showSheet) {
|
||||
/// SettingsView()
|
||||
/// .liquidGlassSheetContent(sourceID: "settings", in: sheetTransition)
|
||||
/// }
|
||||
/// ```
|
||||
func liquidGlassSheetContent(
|
||||
sourceID: String,
|
||||
in namespace: Namespace.ID
|
||||
) -> some View {
|
||||
modifier(LiquidGlassSheetContentModifier(sourceID: sourceID, namespace: namespace))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ToolbarContent Extension
|
||||
|
||||
extension ToolbarContent {
|
||||
/// Marks this toolbar content as the source for a Liquid Glass morphing sheet transition.
|
||||
///
|
||||
/// Apply this modifier to a `ToolbarItem` that presents a sheet. On iOS 26+,
|
||||
/// the sheet will morph from this toolbar item when presented.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - id: Unique identifier for the transition (must match the sheet content's sourceID).
|
||||
/// - namespace: A namespace for the matched geometry effect.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// @Namespace private var sheetTransition
|
||||
///
|
||||
/// .toolbar {
|
||||
/// ToolbarItem(placement: .primaryAction) {
|
||||
/// Button { showSheet = true } label: {
|
||||
/// Image(systemName: "gear")
|
||||
/// }
|
||||
/// }
|
||||
/// .liquidGlassTransitionSource(id: "settings", in: sheetTransition)
|
||||
/// }
|
||||
/// ```
|
||||
@ToolbarContentBuilder
|
||||
func liquidGlassTransitionSource(
|
||||
id: String,
|
||||
in namespace: Namespace.ID
|
||||
) -> some ToolbarContent {
|
||||
#if os(iOS)
|
||||
if #available(iOS 26, *) {
|
||||
self.matchedTransitionSource(id: id, in: namespace)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
36
Yattee/Extensions/View+NavigationSubtitle.swift
Normal file
36
Yattee/Extensions/View+NavigationSubtitle.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// View+NavigationSubtitle.swift
|
||||
// Yattee
|
||||
//
|
||||
// Conditionally applies .navigationSubtitle on iOS 26+ and macOS 26+.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NavigationSubtitleModifier: ViewModifier {
|
||||
let subtitle: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 26, *), let subtitle {
|
||||
content.navigationSubtitle(subtitle)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if #available(macOS 26, *), let subtitle {
|
||||
content.navigationSubtitle(subtitle)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func navigationSubtitleIfAvailable(_ subtitle: String?) -> some View {
|
||||
modifier(NavigationSubtitleModifier(subtitle: subtitle))
|
||||
}
|
||||
}
|
||||
179
Yattee/Extensions/View+ZoomTransition.swift
Normal file
179
Yattee/Extensions/View+ZoomTransition.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// View+ZoomTransition.swift
|
||||
// Yattee
|
||||
//
|
||||
// View modifiers for iOS 18 zoom navigation transitions.
|
||||
// Note: Zoom transitions are only available on iOS. On other platforms,
|
||||
// these modifiers have no effect but are still safe to use.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Environment Keys
|
||||
|
||||
/// Environment key to pass the navigation transition namespace through the view hierarchy.
|
||||
private struct ZoomTransitionNamespaceKey: EnvironmentKey {
|
||||
static let defaultValue: Namespace.ID? = nil
|
||||
}
|
||||
|
||||
/// Environment key to control whether zoom transitions are enabled.
|
||||
private struct ZoomTransitionsEnabledKey: EnvironmentKey {
|
||||
static let defaultValue: Bool = true
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// The namespace used for zoom navigation transitions.
|
||||
var zoomTransitionNamespace: Namespace.ID? {
|
||||
get { self[ZoomTransitionNamespaceKey.self] }
|
||||
set { self[ZoomTransitionNamespaceKey.self] = newValue }
|
||||
}
|
||||
|
||||
/// Whether zoom transitions are enabled. Defaults to true.
|
||||
var zoomTransitionsEnabled: Bool {
|
||||
get { self[ZoomTransitionsEnabledKey.self] }
|
||||
set { self[ZoomTransitionsEnabledKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transition Source Modifier
|
||||
|
||||
/// View modifier that marks a view as the source for a zoom navigation transition.
|
||||
///
|
||||
/// Apply this to a NavigationLink or the view it wraps. When the user navigates
|
||||
/// to the destination, the view will animate with a zoom effect from this source.
|
||||
/// Note: Only has an effect on iOS. On macOS and tvOS, returns the content unchanged.
|
||||
struct ZoomTransitionSourceModifier<ID: Hashable>: ViewModifier {
|
||||
let id: ID
|
||||
@Environment(\.zoomTransitionNamespace) private var namespace
|
||||
@Environment(\.zoomTransitionsEnabled) private var zoomTransitionsEnabled
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if zoomTransitionsEnabled, let namespace {
|
||||
content
|
||||
.matchedTransitionSource(id: id, in: namespace)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transition Destination Modifier
|
||||
|
||||
/// View modifier that applies the zoom navigation transition to a destination view.
|
||||
///
|
||||
/// Apply this to the destination view of a NavigationLink. When navigating to this view,
|
||||
/// it will animate with a zoom effect from the matched source.
|
||||
/// Note: Only has an effect on iOS. On macOS and tvOS, returns the content unchanged.
|
||||
struct ZoomTransitionDestinationModifier<ID: Hashable>: ViewModifier {
|
||||
let id: ID
|
||||
@Environment(\.zoomTransitionNamespace) private var namespace
|
||||
@Environment(\.zoomTransitionsEnabled) private var zoomTransitionsEnabled
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
if zoomTransitionsEnabled, let namespace {
|
||||
content
|
||||
.navigationTransition(.zoom(sourceID: id, in: namespace))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extensions
|
||||
|
||||
extension View {
|
||||
/// Marks this view as the source for a zoom navigation transition.
|
||||
///
|
||||
/// Apply this modifier to a NavigationLink or the view it wraps.
|
||||
/// The id must match the destination's transition id for the zoom effect to work.
|
||||
///
|
||||
/// Note: Only has an effect on iOS. Safe to use on all platforms.
|
||||
///
|
||||
/// - Parameter id: Unique identifier for the transition (e.g., video.id, channel.id).
|
||||
/// - Returns: A view that serves as the source for the zoom transition.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// NavigationLink(value: NavigationDestination.channel(channel.id, source)) {
|
||||
/// ChannelRowView(channel: channel)
|
||||
/// }
|
||||
/// .zoomTransitionSource(id: channel.id)
|
||||
/// ```
|
||||
func zoomTransitionSource<ID: Hashable>(id: ID) -> some View {
|
||||
modifier(ZoomTransitionSourceModifier(id: id))
|
||||
}
|
||||
|
||||
/// Applies the zoom navigation transition to this destination view.
|
||||
///
|
||||
/// Apply this modifier to the destination view of a NavigationLink.
|
||||
/// The id must match the source's transition id for the zoom effect to work.
|
||||
///
|
||||
/// Note: Only has an effect on iOS. Safe to use on all platforms.
|
||||
///
|
||||
/// - Parameter id: Unique identifier matching the source's id.
|
||||
/// - Returns: A view with the zoom transition applied.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// ChannelView(channel: channel)
|
||||
/// .zoomTransitionDestination(id: channel.id)
|
||||
/// ```
|
||||
func zoomTransitionDestination<ID: Hashable>(id: ID) -> some View {
|
||||
modifier(ZoomTransitionDestinationModifier(id: id))
|
||||
}
|
||||
|
||||
/// Injects the zoom transition namespace into the environment.
|
||||
///
|
||||
/// Apply this modifier to a NavigationStack to enable zoom transitions
|
||||
/// for all NavigationLinks within that stack.
|
||||
///
|
||||
/// - Parameter namespace: The namespace to use for matched transitions.
|
||||
/// - Returns: A view with the namespace injected into the environment.
|
||||
///
|
||||
/// Example:
|
||||
/// ```swift
|
||||
/// @Namespace private var zoomTransition
|
||||
///
|
||||
/// NavigationStack {
|
||||
/// ContentView()
|
||||
/// }
|
||||
/// .zoomTransitionNamespace(zoomTransition)
|
||||
/// ```
|
||||
func zoomTransitionNamespace(_ namespace: Namespace.ID) -> some View {
|
||||
environment(\.zoomTransitionNamespace, namespace)
|
||||
}
|
||||
|
||||
/// Injects the zoom transition namespace into the environment (optional overload).
|
||||
///
|
||||
/// If the namespace is nil, the view is returned unchanged.
|
||||
///
|
||||
/// - Parameter namespace: The optional namespace to use for matched transitions.
|
||||
/// - Returns: A view with the namespace injected into the environment, or unchanged if nil.
|
||||
@ViewBuilder
|
||||
func zoomTransitionNamespace(_ namespace: Namespace.ID?) -> some View {
|
||||
if let namespace {
|
||||
environment(\.zoomTransitionNamespace, namespace)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets whether zoom transitions are enabled.
|
||||
///
|
||||
/// Apply this at a high level in the view hierarchy (e.g., ContentView) to control
|
||||
/// whether zoom navigation transitions are applied throughout the app.
|
||||
///
|
||||
/// - Parameter enabled: Whether zoom transitions should be enabled.
|
||||
/// - Returns: A view with the zoom transitions enabled state set.
|
||||
func zoomTransitionsEnabled(_ enabled: Bool) -> some View {
|
||||
environment(\.zoomTransitionsEnabled, enabled)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user