├── .github ├── FUNDING.yml └── images │ └── effect.gif ├── .gitignore ├── Sources └── Sticker │ ├── StickerPattern │ └── StickerPatternType.swift │ ├── StickerMotion │ └── StickerMotion.swift │ ├── Utils │ ├── ViewSize │ │ ├── ViewSizePreferenceKey.swift │ │ └── WithViewSizeViewModifier.swift │ ├── ViewModifiers │ │ └── Accelerometer │ │ │ ├── AccelerometerAttitude.swift │ │ │ └── WithAccelerometerViewModifier.swift │ └── Extensions │ │ └── ShaderLibraryExtension.swift │ ├── StickerMotionEffect │ ├── Effects │ │ ├── IdentityStickerMotionEffect.swift │ │ ├── PointerHoverStickerMotionEffect.swift │ │ ├── DragStickerMotionEffect.swift │ │ └── AccelerometerStickerMotionEffect.swift │ └── StickerMotionEffect.swift │ ├── StickerTransform │ └── StickerTransform.swift │ ├── Resources │ └── Shaders │ │ ├── ReflectionShader.metal │ │ └── FoilShader.metal │ ├── StickerShaderUpdater │ └── StickerShaderUpdater.swift │ ├── StickerEffectParameter │ └── StickerEffectParameter.swift │ └── StickerEffect │ └── StickerEffect.swift ├── Package.swift ├── LICENSE └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: bpisano 2 | -------------------------------------------------------------------------------- /.github/images/effect.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bpisano/Sticker/HEAD/.github/images/effect.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerPattern/StickerPatternType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerPatternType.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 26/01/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum StickerPatternType: Int, Hashable, Equatable, Sendable { 11 | case diamond = 0 12 | case square = 1 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerMotion/StickerMotion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerMotion.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 04/11/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct StickerMotion: Hashable, Equatable, Sendable { 11 | public var isActive: Bool = false 12 | public var transform: StickerTransform = .neutral 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Sticker/Utils/ViewSize/ViewSizePreferenceKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewSizePreferenceKey.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ViewSizePreferenceKey: PreferenceKey { 11 | static let defaultValue: CGSize = .zero 12 | 13 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 14 | value = nextValue() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Sticker", 7 | platforms: [ 8 | .iOS(.v17), 9 | .macOS(.v14), 10 | .visionOS(.v1) 11 | ], 12 | products: [ 13 | .library( 14 | name: "Sticker", 15 | targets: ["Sticker"] 16 | ), 17 | ], 18 | targets: [ 19 | .target(name: "Sticker"), 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerMotionEffect/Effects/IdentityStickerMotionEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentityStickerMotionEffect.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct IdentityStickerMotionEffect: StickerMotionEffect { 11 | public func body(content: Content) -> some View { 12 | content 13 | } 14 | } 15 | 16 | extension StickerMotionEffect where Self == IdentityStickerMotionEffect { 17 | public static var identity: Self { 18 | .init() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerMotionEffect/StickerMotionEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerMotionEffect.swift 3 | // FoilTest 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public protocol StickerMotionEffect: ViewModifier { } 11 | 12 | extension View { 13 | func applyTransform(for effect: some StickerMotionEffect) -> AnyView { 14 | AnyView(modifier(effect)) 15 | } 16 | 17 | public func stickerMotionEffect(_ effect: some StickerMotionEffect) -> some View { 18 | environment(\.stickerMotionEffect, effect) 19 | } 20 | } 21 | 22 | public extension EnvironmentValues { 23 | @Entry var stickerMotionEffect: any StickerMotionEffect = IdentityStickerMotionEffect() 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerTransform/StickerTransform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerTransform.swift 3 | // FoilTest 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct StickerTransform: Equatable, Hashable, Sendable { 11 | public static let neutral: StickerTransform = .init(x: 0.5, y: 0.5) 12 | 13 | public let x: Double 14 | public let y: Double 15 | 16 | public var point: CGPoint { 17 | .init(x: x, y: y) 18 | } 19 | 20 | public init( 21 | x: Double, 22 | y: Double 23 | ) { 24 | self.x = x 25 | self.y = y 26 | } 27 | 28 | public static func * (lhs: StickerTransform, rhs: CGFloat) -> StickerTransform { 29 | .init(x: lhs.x * rhs, y: lhs.y * rhs) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Sticker/Utils/ViewModifiers/Accelerometer/AccelerometerAttitude.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 15/11/2024. 6 | // 7 | 8 | import Foundation 9 | import CoreMotion 10 | 11 | struct AccelerometerAttitude: Equatable, Hashable, Sendable { 12 | let pitch: Double 13 | let roll: Double 14 | let yaw: Double 15 | 16 | init( 17 | pitch: Double = 0, 18 | roll: Double = 0, 19 | yaw: Double = 0 20 | ) { 21 | self.pitch = pitch 22 | self.roll = roll 23 | self.yaw = yaw 24 | } 25 | 26 | init(attitude: CMAttitude) { 27 | self.pitch = attitude.pitch 28 | self.roll = attitude.roll 29 | self.yaw = attitude.yaw 30 | } 31 | 32 | static func - (lhs: AccelerometerAttitude, rhs: AccelerometerAttitude) -> AccelerometerAttitude { 33 | .init( 34 | pitch: lhs.pitch - rhs.pitch, 35 | roll: lhs.roll - rhs.roll, 36 | yaw: lhs.yaw - rhs.yaw 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Sticker/Resources/Shaders/ReflectionShader.metal: -------------------------------------------------------------------------------- 1 | // 2 | // ReflectionShader.metal 3 | // FoilTest 4 | // 5 | // Created by Benjamin Pisano on 31/10/2024. 6 | // 7 | 8 | #include 9 | #include 10 | 11 | using namespace metal; 12 | 13 | [[ stitchable ]] half4 reflection(float2 position, half4 color, float2 size, float2 reflectionPosition, float reflectionSize, float intensity) { 14 | // Normalize the current position to UV coordinates 15 | float2 uv = position / size; 16 | 17 | // Calculate the distance between the UV position and the normalized reflection position 18 | float d = distance(uv, reflectionPosition); 19 | 20 | // Create a gradient based on the distance to achieve a smooth, blurred edge 21 | float blurFactor = smoothstep(reflectionSize / size.x, 0.0, d); 22 | 23 | // Calculate the reflection color based on intensity and blur factor 24 | half4 reflectionColor = half4(1.0, 1.0, 1.0, intensity * blurFactor); 25 | 26 | // Blend the reflection with the original color 27 | return mix(color, reflectionColor, reflectionColor.a); 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 bpisano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Sticker/Utils/ViewSize/WithViewSizeViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WithViewSizeViewModifier.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WithSizeViewModifier: ViewModifier { 11 | let makeView: (AnyView, CGSize) -> ModifiedContent 12 | 13 | @State private var size: CGSize = .zero 14 | 15 | func body(content: Content) -> some View { 16 | makeView(AnyView(content), size) 17 | .background { 18 | GeometryReader { proxy in 19 | Color.clear 20 | .preference(key: ViewSizePreferenceKey.self, value: proxy.size) 21 | } 22 | } 23 | .onPreferenceChange(ViewSizePreferenceKey.self) { [$size] newSize in 24 | $size.wrappedValue = newSize 25 | } 26 | } 27 | } 28 | 29 | extension View { 30 | func withViewSize( 31 | @ViewBuilder _ makeView: @escaping (_ view: AnyView, _ size: CGSize) -> ModifiedContent 32 | ) -> some View { 33 | modifier(WithSizeViewModifier(makeView: makeView)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerShaderUpdater/StickerShaderUpdater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerShaderUpdater.swift 3 | // FoilTest 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Observation 10 | 11 | @Observable 12 | final class StickerShaderUpdater { 13 | typealias ChangeHandler = (_ motion: StickerMotion) -> Void 14 | 15 | private(set) var motion: StickerMotion = .init() 16 | 17 | private let onChange: ChangeHandler 18 | 19 | init(onChange: @escaping @Sendable ChangeHandler) { 20 | self.onChange = onChange 21 | } 22 | 23 | @MainActor 24 | func update(with transform: StickerTransform) { 25 | motion = .init( 26 | isActive: true, 27 | transform: transform 28 | ) 29 | onChange(motion) 30 | } 31 | 32 | @MainActor 33 | func setNeutral() { 34 | motion = .init( 35 | isActive: false, 36 | transform: .neutral 37 | ) 38 | onChange(motion) 39 | } 40 | } 41 | 42 | extension StickerShaderUpdater: Hashable { 43 | func hash(into hasher: inout Hasher) { 44 | hasher.combine(motion) 45 | } 46 | } 47 | 48 | extension StickerShaderUpdater: Equatable { 49 | static func == (lhs: StickerShaderUpdater, rhs: StickerShaderUpdater) -> Bool { 50 | lhs.motion == rhs.motion 51 | } 52 | } 53 | 54 | extension View { 55 | func onStickerShaderChange(_ onChange: @escaping @Sendable StickerShaderUpdater.ChangeHandler) -> some View { 56 | environment(\.stickerShaderUpdater, .init(onChange: onChange)) 57 | } 58 | } 59 | 60 | extension EnvironmentValues { 61 | @Entry var stickerShaderUpdater: StickerShaderUpdater = .init(onChange: { _ in }) 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerMotionEffect/Effects/PointerHoverStickerMotionEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PointerHoverStickerMotionEffect.swift 3 | // FoilTest 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct PointerHoverStickerMotionEffect: StickerMotionEffect { 11 | let intensity: Double 12 | 13 | @State private var transform: StickerTransform = .neutral 14 | 15 | @Environment(\.stickerShaderUpdater) private var shaderUpdater 16 | 17 | public func body(content: Content) -> some View { 18 | content 19 | .withViewSize { view, size in 20 | let xRotation: Double = (transform.x / size.width) * intensity 21 | let yRotation: Double = (transform.y / size.height) * intensity 22 | view 23 | .rotation3DEffect(.radians(xRotation), axis: (0, 1, 0)) 24 | .rotation3DEffect(.radians(yRotation), axis: (-1, 0, 0)) 25 | .onContinuousHover { phase in 26 | switch phase { 27 | case .active(let location): 28 | transform = .init( 29 | x: location.x - size.width / 2, 30 | y: location.y - size.height / 2 31 | ) 32 | shaderUpdater.update(with: transform) 33 | case .ended: 34 | transform = .neutral 35 | shaderUpdater.setNeutral() 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | public extension StickerMotionEffect where Self == PointerHoverStickerMotionEffect { 43 | static var pointerHover: Self { 44 | .pointerHover() 45 | } 46 | 47 | static func pointerHover(intensity: Double = 1) -> Self { 48 | .init(intensity: intensity) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerMotionEffect/Effects/DragStickerMotionEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 13/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct DragStickerMotionEffect: StickerMotionEffect { 11 | let intensity: Double 12 | 13 | @State private var transform: StickerTransform = .neutral 14 | 15 | @Environment(\.stickerShaderUpdater) private var shaderUpdater 16 | 17 | public func body(content: Content) -> some View { 18 | content 19 | .withViewSize { view, size in 20 | let xRotation: Double = (transform.x / size.width) * intensity 21 | let yRotation: Double = (transform.y / size.height) * intensity 22 | view 23 | .rotation3DEffect(.radians(xRotation), axis: (0, 1, 0)) 24 | .rotation3DEffect(.radians(yRotation), axis: (-1, 0, 0)) 25 | .gesture( 26 | DragGesture() 27 | .onChanged { gesture in 28 | transform = .init( 29 | x: gesture.location.x - size.width / 2, 30 | y: gesture.location.y - size.height / 2 31 | ) 32 | shaderUpdater.update(with: transform) 33 | } 34 | .onEnded { _ in 35 | transform = .neutral 36 | shaderUpdater.setNeutral() 37 | } 38 | ) 39 | } 40 | } 41 | } 42 | 43 | public extension StickerMotionEffect where Self == DragStickerMotionEffect { 44 | static var dragGesture: Self { 45 | .dragGesture() 46 | } 47 | 48 | static func dragGesture(intensity: Double = 1) -> Self { 49 | .init(intensity: intensity) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Sticker/Utils/Extensions/ShaderLibraryExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShaderLibraryExtension.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension ShaderLibrary { 11 | static var moduleLibrary: ShaderLibrary { .bundle(.module) } 12 | 13 | static func foilShader( 14 | offset: CGPoint = .zero, 15 | size: CGSize = .zero, 16 | scale: Float = 2, 17 | intensity: Float = 0.8, 18 | contrast: Float = 0.9, 19 | blendFactor: Float = 0.4, 20 | checkerScale: Float = 5, 21 | checkerIntensity: Float = 1.2, 22 | noiseScale: Float = 100, 23 | noiseIntensity: Float = 1.2, 24 | patternType: StickerPatternType = .diamond 25 | ) -> Shader { 26 | moduleLibrary.foil( 27 | .float2(offset), 28 | .float2(size), 29 | .float(scale), 30 | .float(intensity), 31 | .float(contrast), 32 | .float(blendFactor), 33 | .float(checkerScale), 34 | .float(checkerIntensity), 35 | .float(noiseScale), 36 | .float(noiseIntensity), 37 | .float(Float(patternType.rawValue)) 38 | ) 39 | } 40 | 41 | static func reflectionShader( 42 | size: CGSize = .zero, 43 | reflectionPosition: CGPoint = .zero, 44 | reflectionSize: Float = 0, 45 | reflectionIntensity: Float = 0 46 | ) -> Shader { 47 | moduleLibrary.reflection( 48 | .float2(size), 49 | .float2(reflectionPosition), 50 | .float(reflectionSize), 51 | .float(reflectionIntensity) 52 | ) 53 | } 54 | } 55 | 56 | public extension ShaderLibrary { 57 | @available(iOS 18.0, macOS 15.0, visionOS 2.0, tvOS 18.0, watchOS 11.0, *) 58 | static func compileStickerShaders() async throws { 59 | try await foilShader().compile(as: .colorEffect) 60 | try await reflectionShader().compile(as: .colorEffect) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerEffectParameter/StickerEffectParameter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerEffectParameter.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 05/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension EnvironmentValues { 11 | @Entry var stickerScale: Float = 3 12 | @Entry var stickerColorIntensity: Float = 0.8 13 | @Entry var stickerContrast: Float = 0.9 14 | @Entry var stickerBlend: Float = 0.4 15 | @Entry var stickerCheckerScale: Float = 5 16 | @Entry var stickerCheckerIntensity: Float = 1.2 17 | @Entry var stickerNoiseScale: Float = 100 18 | @Entry var stickerNoiseIntensity: Float = 1.2 19 | @Entry var stickerLightIntensity: Float = 0.3 20 | @Entry var stickerPattern: StickerPatternType = .diamond 21 | } 22 | 23 | extension View { 24 | public func stickerScale(_ scale: Float) -> some View { 25 | environment(\.stickerScale, scale) 26 | } 27 | 28 | public func stickerColorIntensity(_ intensity: Float) -> some View { 29 | environment(\.stickerColorIntensity, intensity) 30 | } 31 | 32 | public func stickerContrast(_ contrast: Float) -> some View { 33 | environment(\.stickerContrast, contrast) 34 | } 35 | 36 | public func stickerBlend(_ blendFactor: Float) -> some View { 37 | environment(\.stickerBlend, blendFactor) 38 | } 39 | 40 | public func stickerCheckerScale(_ scale: Float) -> some View { 41 | environment(\.stickerCheckerScale, scale) 42 | } 43 | 44 | public func stickerCheckerIntensity(_ intensity: Float) -> some View { 45 | environment(\.stickerCheckerIntensity, intensity) 46 | } 47 | 48 | public func stickerNoiseScale(_ scale: Float) -> some View { 49 | environment(\.stickerNoiseScale, scale) 50 | } 51 | 52 | public func stickerNoiseIntensity(_ intensity: Float) -> some View { 53 | environment(\.stickerNoiseIntensity, intensity) 54 | } 55 | 56 | public func stickerLightIntensity(_ intensity: Float) -> some View { 57 | environment(\.stickerLightIntensity, intensity) 58 | } 59 | 60 | public func stickerPattern(_ type: StickerPatternType) -> some View { 61 | environment(\.stickerPattern, type) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Sticker/Utils/ViewModifiers/Accelerometer/WithAccelerometerViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 15/11/2024. 6 | // 7 | 8 | #if os(iOS) 9 | import SwiftUI 10 | import CoreMotion 11 | 12 | private struct WithAccelerometerViewModifier: ViewModifier { 13 | let updateInterval: TimeInterval 14 | let makeView: (AnyView, AccelerometerAttitude) -> ModifiedContent 15 | 16 | private let motionManager = CMMotionManager() 17 | 18 | @State private var attitude: AccelerometerAttitude = .init() 19 | @State private var referenceAttitude: AccelerometerAttitude? 20 | 21 | func body(content: Content) -> some View { 22 | makeView(AnyView(content), attitude) 23 | .onAppear { 24 | startMotionUpdates() 25 | } 26 | .onDisappear { 27 | stopMotionUpdates() 28 | } 29 | } 30 | 31 | private func startMotionUpdates() { 32 | guard motionManager.isDeviceMotionAvailable else { return } 33 | motionManager.deviceMotionUpdateInterval = updateInterval 34 | motionManager.startDeviceMotionUpdates(to: .main) { motion, error in 35 | guard let motion = motion, error == nil else { return } 36 | var currentAttitude: AccelerometerAttitude = .init(attitude: motion.attitude) 37 | 38 | DispatchQueue.main.async { 39 | if referenceAttitude == nil { 40 | referenceAttitude = currentAttitude 41 | } 42 | 43 | if let referenceAttitude { 44 | currentAttitude = currentAttitude - referenceAttitude 45 | } 46 | 47 | attitude = currentAttitude 48 | } 49 | } 50 | } 51 | 52 | private func stopMotionUpdates() { 53 | motionManager.stopDeviceMotionUpdates() 54 | } 55 | } 56 | 57 | extension View { 58 | func withAccelerometer( 59 | updateInterval: TimeInterval, 60 | @ViewBuilder _ makeView: @escaping (_ view: AnyView, _ attitude: AccelerometerAttitude) -> ModifiedContent 61 | ) -> some View { 62 | modifier( 63 | WithAccelerometerViewModifier( 64 | updateInterval: updateInterval, 65 | makeView: makeView 66 | ) 67 | ) 68 | } 69 | } 70 | 71 | #endif 72 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerMotionEffect/Effects/AccelerometerStickerMotionEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 15/11/2024. 6 | // 7 | 8 | #if os(iOS) 9 | import SwiftUI 10 | import CoreMotion 11 | 12 | public struct AccelerometerStickerMotionEffect: StickerMotionEffect { 13 | let intensity: Double 14 | let maxRotation: Angle 15 | let updateInterval: TimeInterval 16 | 17 | @Environment(\.stickerShaderUpdater) private var shaderUpdater 18 | 19 | public func body(content: Content) -> some View { 20 | content 21 | .withViewSize { view, size in 22 | view 23 | .withAccelerometer(updateInterval: updateInterval) { view, attitude in 24 | let xRotation: Double = diminishingRotation(for: attitude.roll * intensity) 25 | let yRotation: Double = diminishingRotation(for: attitude.pitch * intensity) 26 | 27 | view 28 | .rotation3DEffect(.radians(xRotation), axis: (0, 1, 0)) 29 | .rotation3DEffect(.radians(yRotation), axis: (-1, 0, 0)) 30 | .onChange(of: attitude) { oldValue, newValue in 31 | shaderUpdater.update( 32 | with: .init( 33 | x: xRotation * size.width / 2, 34 | y: yRotation * size.height / 2 35 | ) 36 | ) 37 | } 38 | } 39 | } 40 | } 41 | 42 | private func diminishingRotation(for tilt: Double) -> Double { 43 | let scale: Double = 1 / (1 + abs(tilt) / maxRotation.radians) 44 | return tilt * scale 45 | } 46 | } 47 | 48 | public extension StickerMotionEffect where Self == AccelerometerStickerMotionEffect { 49 | static var accelerometer: Self { 50 | .accelerometer() 51 | } 52 | 53 | static func accelerometer( 54 | intensity: Double = 1, 55 | maxRotation: Angle = .degrees(90), 56 | updateInterval: TimeInterval = 0.02 57 | ) -> Self { 58 | .init( 59 | intensity: intensity, 60 | maxRotation: maxRotation, 61 | updateInterval: updateInterval 62 | ) 63 | } 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /Sources/Sticker/StickerEffect/StickerEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickerEffect.swift 3 | // Sticker 4 | // 5 | // Created by Benjamin Pisano on 03/11/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct StickerEffectViewModifier: ViewModifier { 11 | @State private var motion: StickerMotion = .init() 12 | 13 | @Environment(\.stickerMotionEffect) private var effect 14 | @Environment(\.stickerScale) private var stickerScale 15 | @Environment(\.stickerColorIntensity) private var stickerColorIntensity 16 | @Environment(\.stickerContrast) private var stickerContrast 17 | @Environment(\.stickerBlend) private var stickerBlend 18 | @Environment(\.stickerCheckerScale) private var stickerCheckerScale 19 | @Environment(\.stickerCheckerIntensity) private var stickerCheckerIntensity 20 | @Environment(\.stickerNoiseScale) private var stickerNoiseScale 21 | @Environment(\.stickerNoiseIntensity) private var stickerNoiseIntensity 22 | @Environment(\.stickerLightIntensity) private var stickerLightIntensity 23 | @Environment(\.stickerPattern) private var stickerPattern 24 | 25 | func body(content: Content) -> some View { 26 | content 27 | .visualEffect { [motion, stickerLightIntensity] view, proxy in 28 | view 29 | .colorEffect( 30 | ShaderLibrary.reflectionShader( 31 | size: proxy.size, 32 | reflectionPosition: CGPoint( 33 | x: (motion.transform.x + proxy.size.width / 2) / proxy.size.width, 34 | y: (motion.transform.y + proxy.size.height / 2) / proxy.size.height 35 | ), 36 | reflectionSize: Float(min(proxy.size.width, proxy.size.height) / 2), 37 | reflectionIntensity: motion.isActive ? stickerLightIntensity : 0 38 | ) 39 | ) 40 | } 41 | .visualEffect { 42 | [ 43 | motion, stickerScale, stickerColorIntensity, stickerContrast, stickerBlend, 44 | stickerCheckerScale, stickerCheckerIntensity, stickerNoiseScale, 45 | stickerNoiseIntensity, stickerPattern 46 | ] view, proxy in 47 | view 48 | .colorEffect( 49 | ShaderLibrary.foilShader( 50 | offset: motion.isActive 51 | ? (motion.transform * -150).point : StickerTransform.neutral.point, 52 | size: proxy.size, 53 | scale: stickerScale, 54 | intensity: stickerColorIntensity, 55 | contrast: stickerContrast, 56 | blendFactor: stickerBlend, 57 | checkerScale: stickerCheckerScale, 58 | checkerIntensity: stickerCheckerIntensity, 59 | noiseScale: stickerNoiseScale, 60 | noiseIntensity: stickerNoiseIntensity, 61 | patternType: stickerPattern 62 | ) 63 | ) 64 | } 65 | .mask(content) 66 | .applyTransform(for: effect) 67 | .onStickerShaderChange { motion in 68 | self.motion = motion 69 | } 70 | } 71 | } 72 | 73 | extension View { 74 | @ViewBuilder 75 | public func stickerEffect(_ isEnabled: Bool = true) -> some View { 76 | if isEnabled { 77 | modifier(StickerEffectViewModifier()) 78 | } else { 79 | self 80 | } 81 | } 82 | } 83 | 84 | #Preview { 85 | VStack { 86 | Circle() 87 | .fill(.white) 88 | .overlay { 89 | Circle() 90 | .stroke(.black, lineWidth: 2) 91 | .padding(2) 92 | } 93 | .frame(height: 30) 94 | .animation(.snappy) { view in 95 | view 96 | .stickerEffect() 97 | .stickerMotionEffect(.pointerHover) 98 | } 99 | .shadow(radius: 20) 100 | .padding() 101 | 102 | Image(systemName: "applelogo") 103 | .resizable() 104 | .aspectRatio(contentMode: .fit) 105 | .frame(height: 300) 106 | .foregroundStyle(.white) 107 | .animation(.snappy) { view in 108 | view 109 | .stickerEffect() 110 | .stickerMotionEffect(.dragGesture) 111 | } 112 | .shadow(radius: 20) 113 | .padding() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/Sticker/Resources/Shaders/FoilShader.metal: -------------------------------------------------------------------------------- 1 | // 2 | // FoilShader.metal 3 | // FoilTest 4 | // 5 | // Created by Benjamin Pisano on 31/10/2024. 6 | // 7 | 8 | #include 9 | #include 10 | 11 | using namespace metal; 12 | 13 | // A helper function to generate pseudo-random noise based on position 14 | float random(float2 uv) { 15 | return fract(sin(dot(uv.xy, float2(12.9898, 78.233))) * 43758.5453); 16 | } 17 | 18 | // Helper function to calculate brightness 19 | float calculateBrightness(half4 color) { 20 | return (color.r * 0.299 + color.g * 0.587 + color.b * 0.114); 21 | } 22 | 23 | float noisePattern(float2 uv) { 24 | float2 i = floor(uv); 25 | float2 f = fract(uv); 26 | 27 | // Four corners in 2D of a tile 28 | float a = random(i); 29 | float b = random(i + float2(1.0, 0.0)); 30 | float c = random(i + float2(0.0, 1.0)); 31 | float d = random(i + float2(1.0, 1.0)); 32 | 33 | // Smooth Interpolation 34 | float2 u = smoothstep(0.0, 1.0, f); 35 | 36 | // Mix 4 corners percentages 37 | return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; 38 | } 39 | 40 | // Function to mix colors with more intensity on lighter colors 41 | half4 lightnessMix(half4 baseColor, half4 overlayColor, float intensity, float baselineFactor) { 42 | // Calculate brightness of the base color 43 | float brightness = calculateBrightness(baseColor); 44 | 45 | // Adjust mix factor based on brightness, with a minimum baseline for darker colors 46 | float adjustedMixFactor = max(smoothstep(0.2, 1.0, brightness) * intensity, baselineFactor); 47 | 48 | // Perform color mixing 49 | return mix(baseColor, overlayColor, adjustedMixFactor); 50 | } 51 | 52 | // Function to increase contrast based on a pattern value 53 | half4 increaseContrast(half4 source, float pattern, float intensity) { 54 | // Calculate the brightness of the source color 55 | float brightness = calculateBrightness(source); 56 | 57 | // Determine the amount of contrast to apply, based on pattern and brightness 58 | float contrastFactor = mix(1.0, intensity, pattern * brightness); 59 | 60 | // Center the source color around 0.5, apply contrast adjustment, then re-center 61 | half4 contrastedColor = (source - half4(0.5)) * contrastFactor + half4(0.5); 62 | 63 | return contrastedColor; 64 | } 65 | 66 | float squarePattern(float2 uv, float scale, float degreesAngle) { 67 | float radiansAngle = degreesAngle * M_PI_F / 180; 68 | 69 | // Scale the UV coordinates 70 | uv *= scale; 71 | 72 | // Rotate the UV coordinates by the specified angle 73 | float cosAngle = cos(radiansAngle); 74 | float sinAngle = sin(radiansAngle); 75 | float2 rotatedUV = float2( 76 | cosAngle * uv.x - sinAngle * uv.y, 77 | sinAngle * uv.x + cosAngle * uv.y 78 | ); 79 | 80 | // Determine if the current tile is black or white 81 | return fmod(floor(rotatedUV.x) + floor(rotatedUV.y), 2.0) == 0.0 ? 0.0 : 1.0; 82 | } 83 | 84 | float diamondPattern(float2 uv, float scale) { 85 | // Hardcoded angle of 45 degrees for the diamond pattern 86 | return squarePattern(uv, scale, 45.0); 87 | } 88 | 89 | float stickerPattern(int option, float2 uv, float scale) { 90 | switch (option) { 91 | case 0: 92 | return diamondPattern(uv, scale); 93 | case 1: 94 | return squarePattern(uv, scale, 0.0); 95 | default: 96 | return diamondPattern(uv, scale); // Default as diamond for unspecified options 97 | } 98 | } 99 | 100 | [[ stitchable ]] half4 foil( 101 | float2 position, 102 | half4 color, 103 | float2 offset, 104 | float2 size, 105 | float scale, 106 | float intensity, 107 | float contrast, 108 | float blendFactor, 109 | float checkerScale, 110 | float checkerIntensity, 111 | float noiseScale, 112 | float noiseIntensity, 113 | float patternType 114 | ) { 115 | // Calculate aspect ratio (width / height) 116 | float aspectRatio = size.x / size.y; 117 | 118 | // Normalize the offset by dividing by size to keep it consistent across different view sizes 119 | float2 normalizedOffset = (offset + size * 250) / (size * scale) * 0.01; 120 | float2 normalizedPosition = float2(position.x * aspectRatio, position.y); 121 | 122 | // Adjust UV coordinates by adding the normalized offset, then apply scaling 123 | float2 uv = (position / (size * scale)) + normalizedOffset; 124 | 125 | // Scale the noise based on the normalized position and noiseScale parameter 126 | float gradientNoise = random(position) * 0.1; 127 | float pattern = stickerPattern(patternType, normalizedPosition / size * checkerScale, checkerScale); 128 | float noise = noisePattern(position / size * noiseScale); 129 | 130 | // Calculate less saturated color shifts for a metallic effect 131 | half r = half(contrast + 0.25 * sin(uv.x * 10.0 + gradientNoise)); 132 | half g = half(contrast + 0.25 * cos(uv.y * 10.0 + gradientNoise)); 133 | half b = half(contrast + 0.25 * sin((uv.x + uv.y) * 10.0 - gradientNoise)); 134 | 135 | half4 foilColor = half4(r, g, b, 1.0); 136 | half4 mixedFoilColor = lightnessMix(color, foilColor, intensity, 0.3); 137 | 138 | half4 checkerFoil = increaseContrast(mixedFoilColor, pattern, checkerIntensity); 139 | half4 noiseCheckerFoil = increaseContrast(checkerFoil, noise, noiseIntensity); 140 | 141 | return noiseCheckerFoil; 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sticker 2 | 3 | A Swift Package that uses Metal shaders to apply a Pokemon-style foil effect to a view. 4 | 5 | ![Mouse cursor moving over a sticker effect](.github/images/effect.gif) 6 | 7 | # Installation 8 | 9 | Add the following dependency to your `Package.swift` file: 10 | 11 | ```swift 12 | dependencies: [ 13 | .package(url: "https://github.com/bpisano/sticker", .upToNextMajor(from: "1.3.0")) 14 | ] 15 | ``` 16 | 17 | # Usage 18 | 19 | Use the `.stickerEffect()` view modifier to apply the effect to any view. 20 | 21 | ```swift 22 | import Sticker 23 | 24 | struct ContentView: View { 25 | var body: some View { 26 | Image(.stickerIcon) 27 | .stickerEffect() 28 | } 29 | } 30 | ``` 31 | 32 | By default, the effect is not animated. 33 | 34 | ## Parameters 35 | 36 | The following modifiers are available to customize the sticker effect: 37 | 38 | | Modifier | Default Value | Description | 39 | | ------------------------------ | ------------- | ------------------------------------------------------------- | 40 | | `.stickerScale(_:)` | 3.0 | Controls the overall scale of the effect pattern | 41 | | `.stickerColorIntensity(_:)` | 0.8 | Adjusts the strength of the holographic effect | 42 | | `.stickerContrast(_:)` | 0.9 | Modifies the contrast between light and dark areas | 43 | | `.stickerBlend(_:)` | 0.4 | Controls how much the effect blends with the original content | 44 | | `.stickerCheckerScale(_:)` | 5.0 | Adjusts the scale of the checker pattern | 45 | | `.stickerCheckerIntensity(_:)` | 1.2 | Controls the intensity of the checker effect | 46 | | `.stickerNoiseScale(_:)` | 100.0 | Adjusts the scale of the noise pattern | 47 | | `.stickerNoiseIntensity(_:)` | 1.2 | Controls the intensity of the noise effect | 48 | | `.stickerLightIntensity(_:)` | 0.3 | Adjusts the intensity of the light reflection | 49 | | `.stickerPattern(_:)` | diamond | Modifies the checker pattern | 50 | 51 | Example usage: 52 | 53 | ```swift 54 | Image(.stickerIcon) 55 | .stickerEffect() 56 | .stickerColorIntensity(0.5) 57 | .stickerNoiseScale(200) 58 | .stickerLightIntensity(0.5) 59 | ``` 60 | 61 | ## Motion 62 | 63 | The effect can be animated using the `.stickerMotionEffect()` view modifier. 64 | 65 | ```swift 66 | Image(.stickerIcon) 67 | .stickerEffect() 68 | .stickerMotionEffect(.pointerHover(intensity: 0.5)) 69 | ``` 70 | 71 | Using the `.animation` view modifier allows you to control the animation of the effect. 72 | 73 | ```swift 74 | Image(.stickerIcon) 75 | .animation(.snappy) { view in 76 | view 77 | .stickerEffect() 78 | .stickerMotionEffect(.pointerHover(intensity: 0.5)) 79 | } 80 | ``` 81 | 82 | The following motion effects are available: 83 | 84 | | Effect | Description | 85 | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 86 | | `.pointerHover(intensity:)` | Apply a 3D transform that looks at the pointer. The `intensity` parameter controls the strength of the effect. | 87 | | `.dragGesture(intensity:)` | Apply a 3D transform that follows drag gestures. The `intensity` parameter controls the strength of the effect. | 88 | | `.accelerometer(intensity:maxRotation:updateInterval:)` | Apply a 3D transform based on the device's accelerometer data. The `intensity` parameter controls the strength of the effect, while `maxRotation` defines the maximum rotation angle, applying a smooth rotation to the specified angle. | 89 | | `.identity` | Remove the motion effect. | 90 | 91 | You can create your own motion effects by implementing the `StickerMotionEffect` protocol. 92 | 93 | ```swift 94 | struct MyMotionEffect: StickerMotionEffect { 95 | let startDate: Date = .init() 96 | 97 | func body(content: Content) -> some View { 98 | // Implement your motion effect here. 99 | // This example applies a sine wave rotation to the content. 100 | TimelineView(.animation) { context in 101 | let elapsedTime = context.date.timeIntervalSince(startDate) 102 | let rotation = sin(elapsedTime * 2) * 10 103 | content 104 | .rotationEffect(.degrees(rotation)) 105 | } 106 | } 107 | } 108 | 109 | extension StickerMotionEffect where Self == MyMotionEffect { 110 | static var myMotionEffect: Self { .init() } 111 | } 112 | ``` 113 | 114 | ## Compiling Shaders 115 | 116 | On iOS 18, macOS 15 and visionOS 2, Apple added the ability to compile Metal shaders at runtime to prevent frame delay on first use. You can compile the Sticker shaders as soon as your app launches by calling the following function: 117 | 118 | ```swift 119 | import SwiftUI 120 | import Sticker 121 | 122 | @main 123 | struct MyApp: App { 124 | var body: some Scene { 125 | WindowGroup { 126 | ContentView() 127 | .task { 128 | await ShaderLibrary.compileStickerShaders() 129 | } 130 | } 131 | } 132 | } 133 | ``` 134 | --------------------------------------------------------------------------------