/** * https://gist.github.com/tadija/2fa1a99ccf3413cd0e30e3633e1a32db * Revision 18 * Copyright © 2020-2021 Marko Tadić * Licensed under the MIT license */ import SwiftUI import Combine // swiftlint:disable file_length // MARK: - Top Level public var isXcodePreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } // MARK: - Color public extension Color { static func dynamic(light: Color, dark: Color) -> Color { Color(UIColor( dynamicProvider: { $0.isDark ? UIColor(dark) : UIColor(light) }) ) } } public extension UITraitCollection { var isDark: Bool { userInterfaceStyle == .dark } } // MARK: - Layout public struct LayoutFill: View { let color: Color let alignment: Alignment var content: T public init(_ color: Color = .clear, alignment: Alignment = .center, @ViewBuilder content: () -> T) { self.color = color self.alignment = alignment self.content = content() } public var body: some View { color.overlay(content, alignment: alignment) } } public struct LayoutCenter: View { let axis: Axis var content: T public init(_ axis: Axis, @ViewBuilder content: () -> T) { self.axis = axis self.content = content() } public var body: some View { switch axis { case .horizontal: HStack(spacing: 0) { centeredContent } case .vertical: VStack(spacing: 0) { centeredContent } } } @ViewBuilder private var centeredContent: some View { Spacer() content Spacer() } } public struct LayoutHalf: View { let edge: Edge var content: T public init(_ edge: Edge, @ViewBuilder content: () -> T) { self.edge = edge self.content = content() } public var body: some View { switch edge { case .top: VStack(spacing: 0) { content Color.clear } case .bottom: VStack(spacing: 0) { Color.clear content } case .leading: HStack(spacing: 0) { content Color.clear } case .trailing: HStack(spacing: 0) { Color.clear content } } } } public struct LayoutAlign: View { let alignment: Alignment var content: T public init(_ alignment: Alignment, @ViewBuilder content: () -> T) { self.alignment = alignment self.content = content() } public var body: some View { switch alignment { case .top: Top { content } case .bottom: Bottom { content } case .leading: Leading { content } case .trailing: Trailing { content } case .topLeading: Top { Leading { content } } case .topTrailing: Top { Trailing { content } } case .bottomLeading: Bottom { Leading { content } } case .bottomTrailing: Bottom { Trailing { content } } default: fatalError("\(alignment) is not supported") } } private struct Top: View { var content: () -> T var body: some View { VStack(spacing: 0) { content() Spacer() } } } private struct Bottom: View { var content: () -> T var body: some View { VStack(spacing: 0) { Spacer() content() } } } private struct Leading: View { var content: () -> T var body: some View { HStack(spacing: 0) { content() Spacer() } } } private struct Trailing: View { var content: () -> T var body: some View { HStack(spacing: 0) { Spacer() content() } } } } public struct LayoutSplit: View { let axis: Axis var firstHalf: T1 var secondHalf: T2 public init( _ axis: Axis, @ViewBuilder firstHalf: () -> T1, @ViewBuilder secondHalf: () -> T2 ) { self.axis = axis self.firstHalf = firstHalf() self.secondHalf = secondHalf() } public var body: some View { switch axis { case .horizontal: HStack(spacing: 0) { Color.clear .overlay(firstHalf) Color.clear .overlay(secondHalf) } case .vertical: VStack(spacing: 0) { Color.clear .overlay(firstHalf) Color.clear .overlay(secondHalf) } } } } // MARK: - View+General /// - See: https://swiftwithmajid.com/2019/12/04/must-have-swiftui-extensions/ public extension View { func eraseToAnyView() -> AnyView { AnyView(self) } func embedInNavigation() -> some View { NavigationView { self } } } // MARK: - View+Notifications /// - See: https://twitter.com/tadija/status/1311263107247943680 public extension View { func onReceive(_ name: Notification.Name, center: NotificationCenter = .default, object: AnyObject? = nil, perform action: @escaping (Notification) -> Void) -> some View { self.onReceive( center.publisher(for: name, object: object), perform: action ) } } // MARK: - View+Condition /// - See: https://fivestars.blog/swiftui/conditional-modifiers.html public extension View { @ViewBuilder func `if`(_ condition: Bool, modifier: (Self) -> T) -> some View { if condition { modifier(self) } else { self } } @ViewBuilder func `if`( _ condition: Bool, if ifModifier: (Self) -> T, else elseModifier: (Self) -> F) -> some View { if condition { ifModifier(self) } else { elseModifier(self) } } @ViewBuilder func ifLet(_ value: V?, modifier: (Self, V) -> T) -> some View { if let value = value { modifier(self, value) } else { self } } } // MARK: - View+Debug /// - See: https://www.swiftbysundell.com/articles/building-swiftui-debugging-utilities/ public extension View { func debugAction(_ closure: () -> Void) -> Self { #if DEBUG closure() #endif return self } func debugLog(_ value: Any) -> Self { debugAction { debugPrint(value) } } } public extension View { func debugModifier(_ modifier: (Self) -> T) -> some View { #if DEBUG return modifier(self) #else return self #endif } func debugBorder(_ color: Color = .red, width: CGFloat = 1) -> some View { debugModifier { $0.border(color, width: width) } } func debugBackground(_ color: Color = .red) -> some View { debugModifier { $0.background(color) } } func debugGesture(_ gesture: G) -> some View { debugModifier { $0.gesture(gesture) } } } // MARK: - View+AnimationCompletion /// - See: https://www.avanderlee.com/swiftui/withanimation-completion-callback /// An animatable modifier that is used for observing animations for a given animatable value. public struct AnimationCompletionObserverModifier: AnimatableModifier where Value: VectorArithmetic { /// While animating, SwiftUI changes the old input value to the new target value using this property. /// This value is set to the old value until the animation completes. public var animatableData: Value { didSet { notifyCompletionIfFinished() } } /// The target value for which we're observing. This value is directly set once the animation starts. /// During animation, `animatableData` will hold the oldValue and is only updated to the target value /// once the animation completes. private var targetValue: Value /// The completion callback which is called once the animation completes. private var completion: () -> Void init(observedValue: Value, completion: @escaping () -> Void) { self.completion = completion self.animatableData = observedValue targetValue = observedValue } /// Verifies whether the current animation is finished and calls the completion callback if true. private func notifyCompletionIfFinished() { guard animatableData == targetValue else { return } /// Dispatching is needed to take the next runloop for the completion callback. DispatchQueue.main.async { self.completion() } } public func body(content: Content) -> some View { /// We're not really modifying the view so we can directly return the original input value. return content } } public extension View { /// Calls the completion handler whenever an animation on the given value completes. /// - Parameters: /// - value: The value to observe for animations. /// - completion: The completion callback to call once the animation completes. /// - Returns: A modified `View` instance with the observer attached. func onAnimationCompleted( for value: Value, completion: @escaping () -> Void ) -> ModifiedContent> { modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion)) } } // MARK: - View+Effects /// - See: https://www.hackingwithswift.com/plus/swiftui-special-effects/shadows-and-glows public extension View { func glow(color: Color = .red, radius: CGFloat = 20) -> some View { self .shadow(color: color, radius: radius / 3) .shadow(color: color, radius: radius / 3) .shadow(color: color, radius: radius / 3) } func innerShadow(using shape: S, angle: Angle = .degrees(0), color: Color = .black, width: CGFloat = 6, blur: CGFloat = 6) -> some View { let finalX = CGFloat(cos(angle.radians - .pi / 2)) let finalY = CGFloat(sin(angle.radians - .pi / 2)) return self .overlay( shape .stroke(color, lineWidth: width) .offset(x: finalX * width * 0.6, y: finalY * width * 0.6) .blur(radius: blur) .mask(shape) ) } } // MARK: - Geometry+Helpers public extension GeometryProxy { var isPortrait: Bool { size.height > size.width } var isLandscape: Bool { size.width > size.height } } // MARK: - List+Helpers public extension List { func hideSeparators() -> some View { self .onAppear { UITableView.appearance().separatorStyle = .none } .onDisappear { UITableView.appearance().separatorStyle = .singleLine } } } // MARK: - ButtonStyle /// - See: https://stackoverflow.com/a/58176268/2165585 public struct ScaleButtonStyle: ButtonStyle { var scale: CGFloat = 2 var animationIn: Animation? = .none var animationOut: Animation? = .default public func makeBody(configuration: Self.Configuration) -> some View { configuration.label .contentShape(Rectangle()) .scaleEffect(configuration.isPressed ? scale : 1) .animation(configuration.isPressed ? animationIn : animationOut) } } // MARK: - PreferenceKey / CGSize /// - See: https://stackoverflow.com/a/63305935/2165585 public protocol CGSizePreferenceKey: PreferenceKey where Value == CGSize {} public extension CGSizePreferenceKey { static func reduce(value _: inout CGSize, nextValue: () -> CGSize) { _ = nextValue() } } public extension View { func onSizeChanged( _ key: Key.Type, perform action: @escaping (CGSize) -> Void) -> some View { self.background(GeometryReader { geo in Color.clear .preference(key: Key.self, value: geo.size) }) .onPreferenceChange(key) { value in action(value) } } } // MARK: - PreferenceKey / CGFloat public protocol CGFloatPreferenceKey: PreferenceKey where Value == CGFloat {} public extension CGFloatPreferenceKey { static func reduce(value: inout Value, nextValue: () -> Value) { value += nextValue() } } public extension View { func changePreference( _ key: Key.Type, using closure: @escaping (GeometryProxy) -> CGFloat) -> some View { self.background(GeometryReader { geo in Color.clear .preference(key: Key.self, value: closure(geo)) }) } } // MARK: - SafeAreaView struct SafeAreaView: View { var edges: Edge.Set var content: () -> T @State private var safeArea: UIEdgeInsets = UIWindow.safeArea var body: some View { content() .padding(.top, edges.contains(.top) ? safeArea.top : 0) .padding(.bottom, edges.contains(.bottom) ? safeArea.bottom : 0) .padding(.leading, edges.contains(.leading) ? safeArea.left : 0) .padding(.trailing, edges.contains(.trailing) ? safeArea.right : 0) .onReceive(UIDevice.orientationDidChangeNotification) { _ in safeArea = UIWindow.safeArea } } } struct SafeAreaViewModifier: ViewModifier { var edges: Edge.Set func body(content: Content) -> some View { SafeAreaView(edges: edges) { content } } } public extension View { func edgesRespectingSafeArea(_ edges: Edge.Set) -> some View { self.modifier(SafeAreaViewModifier(edges: edges)) } } public extension Edge.Set { static let none: Edge.Set = [] } // MARK: - UIWindow+Helpers public extension UIWindow { static var keyWindow: UIWindow? { UIApplication.shared.windows .first(where: { $0.isKeyWindow }) } static var safeArea: UIEdgeInsets { keyWindow?.safeAreaInsets ?? .zero } static var isPortrait: Bool { scene?.interfaceOrientation.isPortrait ?? true } static var isLandscape: Bool { scene?.interfaceOrientation.isLandscape ?? false } static var isSplitOrSlideOver: Bool { guard let window = keyWindow else { return false } return !window.frame.equalTo(window.screen.bounds) } static var statusBarHeight: CGFloat { scene?.statusBarManager?.statusBarFrame.height ?? 0 } static var navigationBarHeight: CGFloat { topViewController?.navigationController?.navigationBar.bounds.height ?? 0 } static var topViewController: UIViewController? { topViewController(keyWindow?.rootViewController) } // MARK: Helpers private class func topViewController(_ vc: UIViewController?) -> UIViewController? { if let nav = vc as? UINavigationController { return topViewController(nav.visibleViewController) } if let tab = vc as? UITabBarController { if let selected = tab.selectedViewController { return topViewController(selected) } } if let presented = vc?.presentedViewController { return topViewController(presented) } return vc } private static var statusBarFrame: CGRect? { scene?.statusBarManager?.statusBarFrame } private static var scene: UIWindowScene? { keyWindow?.windowScene } } // MARK: - KeyboardAdaptive /// - See: https://gist.github.com/scottmatthewman/722987c9ad40f852e2b6a185f390f88d public struct KeyboardAdaptive: ViewModifier { @State private var currentHeight: CGFloat = 0 public func body(content: Content) -> some View { content .padding(.bottom, currentHeight) .edgesIgnoringSafeArea(currentHeight == 0 ? [] : .bottom) .onAppear(perform: subscribeToKeyboardEvents) } private func subscribeToKeyboardEvents() { NotificationCenter.Publisher( center: NotificationCenter.default, name: UIResponder.keyboardWillShowNotification ).compactMap { notification in notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect }.map { rect in rect.height }.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight)) NotificationCenter.Publisher( center: NotificationCenter.default, name: UIResponder.keyboardWillHideNotification ).compactMap { _ in CGFloat.zero }.subscribe(Subscribers.Assign(object: self, keyPath: \.currentHeight)) } } public extension View { func keyboardAdaptive() -> some View { modifier(KeyboardAdaptive()) } } // MARK: - CornerRadius /// - See: https://stackoverflow.com/a/58606176/2165585 public struct RoundedCorner: Shape { public var radius: CGFloat public var corners: UIRectCorner public init(radius: CGFloat = .infinity, corners: UIRectCorner = .allCorners) { self.radius = radius self.corners = corners } public func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius) ) return Path(path.cgPath) } } public extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } } // MARK: - StickyHeader /// - See: https://trailingclosure.com/sticky-header public struct StickyHeader: View { public var minHeight: CGFloat public var content: Content public init( minHeight: CGFloat = 200, @ViewBuilder content: () -> Content ) { self.minHeight = minHeight self.content = content() } public var body: some View { GeometryReader { geo in if geo.frame(in: .global).minY <= 0 { content.frame( width: geo.size.width, height: geo.size.height, alignment: .center ) } else { content .offset(y: -geo.frame(in: .global).minY) .frame( width: geo.size.width, height: geo.size.height + geo.frame(in: .global).minY ) } }.frame(minHeight: minHeight) } } // MARK: - Placeholder public struct Placeholder: View { var text: String public init(_ text: String = "placeholder") { self.text = text } public var body: some View { ZStack { Rectangle() .stroke(style: StrokeStyle(lineWidth: 1, dash: [10])) .foregroundColor(.secondary) Text(text) .multilineTextAlignment(.center) .foregroundColor(.primary) } } } // MARK: - ImageName public enum ImageName { case custom(String) case system(String) } public extension Image { init(_ imageName: ImageName) { switch imageName { case .custom(let name): self = Image(name) case .system(let name): self = Image(systemName: name) } } } // MARK: - Scalable Font public extension Text { enum ScalableFont { case system case custom(String) } func scalableFont(_ scalableFont: ScalableFont = .system, padding: CGFloat = 0) -> some View { self .font(resolveFont(for: scalableFont)) .padding(padding) .minimumScaleFactor(0.01) .lineLimit(1) } private func resolveFont(for scalableFont: ScalableFont) -> Font { switch scalableFont { case .system: return .system(size: 500) case .custom(let name): return .custom(name, size: 500) } } } // MARK: - Binding+Setter /// - See: https://gist.github.com/Amzd/c3015c7e938076fc1e39319403c62950 public extension Binding { func didSet(_ didSet: @escaping ((newValue: Value, oldValue: Value)) -> Void) -> Binding { .init( get: { wrappedValue }, set: { newValue in let oldValue = wrappedValue wrappedValue = newValue didSet((newValue, oldValue)) } ) } func willSet(_ willSet: @escaping ((newValue: Value, oldValue: Value)) -> Void) -> Binding { .init( get: { wrappedValue }, set: { newValue in willSet((newValue, wrappedValue)) wrappedValue = newValue } ) } } // MARK: - State Object for iOS 13 /// - See: https://dev.to/waj/stateobject-alternative-for-ios-13-2271 public struct StateObjectContainer: View where Observable: ObservableObject, Content: View { @State private var object: Observable? private var initializer: () -> Observable private var content: Content public init(_ initializer: @autoclosure @escaping () -> Observable, @ViewBuilder content: () -> Content) { self.content = content() self.initializer = initializer } public var body: some View { if let object = object { content.environmentObject(object) } else { Color.clear.onAppear(perform: initialize) } } private func initialize() { object = initializer() } } // MARK: - Share Sheet /// - See: https://developer.apple.com/forums/thread/123951 public struct ShareSheet: UIViewControllerRepresentable { public typealias Callback = ( _ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error? ) -> Void public let activityItems: [Any] public let applicationActivities: [UIActivity]? public let excludedActivityTypes: [UIActivity.ActivityType]? public let callback: Callback? public init(activityItems: [Any], applicationActivities: [UIActivity]? = nil, excludedActivityTypes: [UIActivity.ActivityType]? = nil, callback: Callback? = nil) { self.activityItems = activityItems self.applicationActivities = applicationActivities self.excludedActivityTypes = excludedActivityTypes self.callback = callback } public func makeUIViewController(context: Context) -> UIActivityViewController { let controller = UIActivityViewController( activityItems: activityItems, applicationActivities: applicationActivities ) controller.excludedActivityTypes = excludedActivityTypes controller.completionWithItemsHandler = callback return controller } public func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } // MARK: - ToggleAsync public struct ToggleAsync: View { @Binding var isOn: Bool var label: () -> T var onValueChanged: ((Bool) -> Void)? public init(isOn: Binding, label: @escaping () -> T, onValueChanged: ((Bool) -> Void)? = nil) { self._isOn = isOn self.label = label self.onValueChanged = onValueChanged } public var body: some View { Toggle( isOn: $isOn .didSet { newValue, oldValue in if newValue != oldValue { onValueChanged?(newValue) } }, label: label ) } } // MARK: - Line public struct Line: Shape { public let x1: CGFloat public let y1: CGFloat public let x2: CGFloat public let y2: CGFloat public init(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat) { self.x1 = x1 self.y1 = y1 self.x2 = x2 self.y2 = y2 } public func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: x1, y: y1)) path.addLine(to: CGPoint(x: x2, y: y2)) return path } } public struct LineTop: Shape { public init() {} public func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) return path } } public struct LineLeft: Shape { public init() {} public func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) return path } } public struct LineBottom: Shape { public init() {} public func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) return path } } public struct LineRight: Shape { public init() {} public func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) return path } } // MARK: - Pie /// - See: https://cs193p.sites.stanford.edu public struct Pie: Shape { var startAngle: Angle var endAngle: Angle var clockwise: Bool = false public init(startAngle: Angle, endAngle: Angle, clockwise: Bool) { self.startAngle = startAngle self.endAngle = endAngle self.clockwise = clockwise } public var animatableData: AnimatablePair { get { AnimatablePair(startAngle.radians, endAngle.radians) } set { startAngle = Angle.radians(newValue.first) endAngle = Angle.radians(newValue.second) } } public func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.width, rect.height) / 2 let start = CGPoint( x: center.x + radius * cos(CGFloat(startAngle.radians)), y: center.y + radius * sin(CGFloat(startAngle.radians)) ) var p = Path() p.move(to: center) p.addLine(to: start) p.addArc( center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: clockwise ) p.addLine(to: center) return p } } // MARK: - Polygon /// - See: https://swiftui-lab.com/swiftui-animations-part1 public struct Polygon: Shape { var sides: Double var scale: Double var drawVertexLines: Bool public init(sides: Double, scale: Double, drawVertexLines: Bool = false) { self.sides = sides self.scale = scale self.drawVertexLines = drawVertexLines } public var animatableData: AnimatablePair { get { AnimatablePair(sides, scale) } set { sides = newValue.first scale = newValue.second } } public func path(in rect: CGRect) -> Path { let hypotenuse = Double(min(rect.size.width, rect.size.height)) / 2.0 * scale let center = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0) var path = Path() let extra: Int = sides != Double(Int(sides)) ? 1 : 0 var vertex: [CGPoint] = [] for i in 0..