├── assets ├── linear_gradient.png └── smooth_gradient.png ├── Makefile ├── .gitignore ├── Tests └── SmoothGradientTests │ ├── __Snapshots__ │ ├── GradientSmoothTests │ │ ├── test_curve_stops.1.png │ │ ├── test_curve_easeIn.1.png │ │ ├── test_curve_easeOut.1.png │ │ ├── test_easing_easeIn.1.png │ │ ├── test_easing_stops.1.png │ │ ├── test_curve_easeInOut.1.png │ │ ├── test_easing_easeInOut.1.png │ │ └── test_easing_easeOut.1.png │ └── SmoothLinearGradientTests │ │ ├── test_stops.1.png │ │ ├── test_diagonal.1.png │ │ ├── test_horizontal.1.png │ │ └── test_vertical.1.png │ ├── CubicBezierCurveTests.swift │ ├── SmoothLinearGradientTests.swift │ └── GradientSmoothTests.swift ├── Sources └── SmoothGradient │ ├── PrivacyInfo.xcprivacy │ ├── Curve.swift │ ├── GradientInterpolator.swift │ ├── SmoothLinearGradient.swift │ ├── CubicBezierCurve.swift │ └── Gradient+Smooth.swift ├── .editorconfig ├── Package.resolved ├── .github └── workflows │ ├── lint.yml │ └── build.yml ├── SmoothGradient.podspec ├── LICENSE ├── Package.swift ├── README.md └── .swiftlint.yml /assets/linear_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/assets/linear_gradient.png -------------------------------------------------------------------------------- /assets/smooth_gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/assets/smooth_gradient.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test lint format 2 | 3 | build: 4 | swift build 5 | 6 | test: 7 | swift test 8 | 9 | lint: 10 | swiftlint 11 | 12 | format: 13 | swiftlint --fix 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_stops.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_stops.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_stops.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_stops.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_easeIn.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_easeIn.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_easeOut.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_easeOut.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_easeIn.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_easeIn.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_stops.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_stops.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_easeInOut.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_curve_easeInOut.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_easeInOut.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_easeInOut.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_easeOut.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/GradientSmoothTests/test_easing_easeOut.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_diagonal.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_diagonal.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_horizontal.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_horizontal.1.png -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_vertical.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raymondjavaxx/SmoothGradient/HEAD/Tests/SmoothGradientTests/__Snapshots__/SmoothLinearGradientTests/test_vertical.1.png -------------------------------------------------------------------------------- /Sources/SmoothGradient/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | 9 | [*.swift] 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [{*.yml,*.podspec,*.rb}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [Makfile] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-snapshot-testing", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/swift-snapshot-testing", 7 | "state" : { 8 | "revision" : "dc46eeb3928a75390651fac6c1ef7f93ad59a73b", 9 | "version" : "1.11.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | paths: 5 | - ".github/workflows/lint.yml" 6 | - ".swiftlint.yml" 7 | - "**/*.swift" 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: SwiftLint 14 | uses: norio-nomura/action-swiftlint@3.2.1 15 | with: 16 | args: --strict 17 | - name: SPM 18 | run: swift package dump-package 19 | -------------------------------------------------------------------------------- /Sources/SmoothGradient/Curve.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Curve.swift 3 | // SmoothGradient 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import SwiftUI 12 | 13 | protocol Curve { 14 | func value(at progress: Double) -> Double 15 | } 16 | 17 | #if compiler(>=5.9) 18 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 19 | extension UnitCurve: Curve {} 20 | #endif 21 | -------------------------------------------------------------------------------- /SmoothGradient.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "SmoothGradient" 3 | spec.version = "1.0.1" 4 | spec.summary = "A SwiftUI package for creating smooth gradients using easing functions." 5 | 6 | spec.homepage = "https://github.com/raymondjavaxx/SmoothGradient" 7 | spec.license = { :type => "MIT", :file => "LICENSE" } 8 | spec.author = { "Ramon Torres" => "raymondjavaxx@gmail.com" } 9 | 10 | spec.swift_version = "5.6" 11 | spec.ios.deployment_target = "14.0" 12 | spec.tvos.deployment_target = "14.0" 13 | spec.osx.deployment_target = "11.0" 14 | spec.watchos.deployment_target = "7.0" 15 | 16 | spec.source = { :git => "https://github.com/raymondjavaxx/SmoothGradient.git", :tag => "#{spec.version}" } 17 | spec.source_files = "Sources/SmoothGradient/**/*.swift" 18 | spec.resources = [ 19 | "Sources/SmoothGradient/PrivacyInfo.xcprivacy" 20 | ] 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-2024 Ramon Torres 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SmoothGradient", 7 | platforms: [ 8 | .iOS(.v14), 9 | .macOS(.v11), 10 | .macCatalyst(.v14), 11 | .tvOS(.v14), 12 | .watchOS(.v7), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "SmoothGradient", 18 | targets: ["SmoothGradient"] 19 | ) 20 | ], 21 | dependencies: [ 22 | .package( 23 | url: "https://github.com/pointfreeco/swift-snapshot-testing", 24 | from: "1.11.0" 25 | ) 26 | ], 27 | targets: [ 28 | .target( 29 | name: "SmoothGradient", 30 | resources: [ 31 | .process("PrivacyInfo.xcprivacy") 32 | ] 33 | ), 34 | .testTarget( 35 | name: "SmoothGradientTests", 36 | dependencies: [ 37 | "SmoothGradient", 38 | .product(name: "SnapshotTesting", package: "swift-snapshot-testing") 39 | ], 40 | exclude: [ 41 | "__Snapshots__" 42 | ] 43 | ) 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/CubicBezierCurveTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CubicBezierCurveTests.swift 3 | // SmoothGradientTests 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | @testable import SmoothGradient 13 | 14 | final class CubicBezierCurveTests: XCTestCase { 15 | func test_easeInOut() throws { 16 | let expected: [Double] = [0, 0.129, 0.227, 0.5, 0.758, 0.870, 1] 17 | for (result, expectedValue) in zip(interpolate(.easeInOut), expected) { 18 | XCTAssertEqual(result, expectedValue, accuracy: 0.001) 19 | } 20 | } 21 | 22 | func test_easeIn() throws { 23 | let expected: [Double] = [0, 0.093, 0.153, 0.315, 0.503, 0.621, 1] 24 | for (result, expectedValue) in zip(interpolate(.easeIn), expected) { 25 | XCTAssertEqual(result, expectedValue, accuracy: 0.001) 26 | } 27 | } 28 | 29 | func test_easeOut() throws { 30 | let expected: [Double] = [0, 0.378, 0.484, 0.684, 0.838, 0.906, 1] 31 | for (result, expectedValue) in zip(interpolate(.easeOut), expected) { 32 | XCTAssertEqual(result, expectedValue, accuracy: 0.001) 33 | } 34 | } 35 | } 36 | 37 | extension CubicBezierCurveTests { 38 | private func interpolate(_ sut: CubicBezierCurve) -> [Double] { 39 | let steps: [Double] = [0, 0.25, 0.33, 0.5, 0.66, 0.75, 1] 40 | return steps.map { sut.value(at: $0) } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | paths: 7 | - ".github/workflows/build.yml" 8 | - "**.swift" 9 | pull_request: 10 | paths: 11 | - ".github/workflows/build.yml" 12 | - "**.swift" 13 | concurrency: 14 | group: build-${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | jobs: 17 | build: 18 | runs-on: macos-14 19 | name: Build ${{ matrix.destination.name }} 20 | strategy: 21 | matrix: 22 | destination: 23 | - name: macOS 24 | value: "platform=macOS,arch=x86_64" 25 | - name: iOS 26 | value: "platform=iOS Simulator,name=iPhone 14,OS=latest" 27 | - name: tvOS 28 | value: "platform=tvOS Simulator,name=Apple TV,OS=latest" 29 | - name: watchOS 30 | value: "platform=watchOS Simulator,name=Apple Watch Series 8 (41mm),OS=latest" 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: maxim-lobanov/setup-xcode@v1 34 | with: 35 | xcode-version: "15.0" 36 | - name: Build 37 | run: |- 38 | set -o pipefail && NSUnbufferedIO=YES xcodebuild clean build \ 39 | -scheme SmoothGradient \ 40 | -destination '${{ matrix.destination.value }}' \ 41 | | xcpretty 42 | - name: Run tests 43 | # only run tests for iOS 44 | if: ${{ matrix.destination.name == 'iOS' }} 45 | run: |- 46 | set -o pipefail && NSUnbufferedIO=YES xcodebuild test \ 47 | -scheme SmoothGradient \ 48 | -destination '${{ matrix.destination.value }}' \ 49 | -sdk iphonesimulator \ 50 | | xcpretty 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SmoothGradient 2 | 3 | A SwiftUI framework for creating smooth gradients using easing functions. 4 | 5 | ## Motivation 6 | 7 | Linear gradients tend to produce hard edges, which are more noticeable when transitioning between highly contrasting colors. This framework provides a simple way to create smoother gradients using easing functions. 8 | 9 | | Linear | Smooth | 10 | | --- | --- | 11 | | ![Linear Gradient](assets/linear_gradient.png) | ![Smooth Gradient](assets/smooth_gradient.png) | 12 | 13 | 14 | ## Installation 15 | 16 | ### Swift Package Manager 17 | 18 | Add the following to your `Package.swift` file: 19 | 20 | ```swift 21 | .package(url: "https://github.com/raymondjavaxx/SmoothGradient.git", from: "1.0.0") 22 | ``` 23 | 24 | To add from Xcode, go to *File* -> *Add Package Dependencies...* and enter the URL above. 25 | 26 | ### CocoaPods 27 | 28 | Add the following to your `Podfile`: 29 | 30 | ```ruby 31 | pod 'SmoothGradient', '~> 1.0.0' 32 | ``` 33 | 34 | ## Usage 35 | 36 | ### Pre-iOS 17/Pre-macOS 14 37 | 38 | ```swift 39 | import SmoothGradient 40 | 41 | struct ContentView: View { 42 | var body: some View { 43 | LinearGradient( 44 | gradient: .smooth(from: .black, to: .white, curve: .easeInOut), // ⬅️ 45 | startPoint: .top, 46 | endPoint: .bottom 47 | ) 48 | } 49 | } 50 | ``` 51 | 52 | ## iOS 17+/macOS 14+ 53 | 54 | ```swift 55 | import SmoothGradient 56 | 57 | struct ContentView: View { 58 | var body: some View { 59 | SmoothLinearGradient( // ⬅️ 60 | from: .black, 61 | to: .white, 62 | startPoint: .top, 63 | endPoint: .bottom, 64 | curve: .easeInOut 65 | ) 66 | } 67 | } 68 | ``` 69 | 70 | ## License 71 | 72 | SmoothGradient is available under the MIT license. See the [LICENSE](LICENSE) file for more info. 73 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | - Tests 4 | 5 | opt_in_rules: 6 | - attributes 7 | - closure_spacing 8 | - collection_alignment 9 | - contains_over_filter_count 10 | - contains_over_filter_is_empty 11 | - contains_over_first_not_nil 12 | - contains_over_range_nil_comparison 13 | - discouraged_object_literal 14 | - discouraged_optional_boolean 15 | - empty_count 16 | - empty_string 17 | - file_header 18 | - file_name 19 | - file_name_no_space 20 | - first_where 21 | - flatmap_over_map_reduce 22 | - force_unwrapping 23 | - identical_operands 24 | - implicit_return 25 | - implicitly_unwrapped_optional 26 | - missing_docs 27 | - number_separator 28 | - operator_usage_whitespace 29 | - overridden_super_call 30 | - prohibited_interface_builder 31 | - sorted_first_last 32 | - toggle_bool 33 | - weak_delegate 34 | - yoda_condition 35 | 36 | analyzer_rules: 37 | - capture_variable 38 | - typesafe_array_init 39 | - unused_declaration 40 | - unused_import 41 | 42 | identifier_name: 43 | min_length: 2 44 | excluded: 45 | - r 46 | - g 47 | - b 48 | - a 49 | - c 50 | - x 51 | - y 52 | - t 53 | 54 | excluded: 55 | - ".build" 56 | 57 | file_header: 58 | required_pattern: | 59 | \/\/ 60 | \/\/ SWIFTLINT_CURRENT_FILENAME 61 | \/\/ (SmoothGradient|SmoothGradientTests) 62 | \/\/ 63 | \/\/ Copyright \(c\) \d{4}(-\d{4})? Ramon Torres 64 | \/\/ 65 | \/\/ This file is part of SmoothGradient which is released under the MIT license\. 66 | \/\/ See the LICENSE file in the root directory of this source tree for full details\. 67 | \/\/ 68 | 69 | file_name: 70 | suffix_pattern: "[+-]{1}.*" 71 | 72 | line_length: 73 | warning: 130 74 | error: 150 75 | ignores_urls: true 76 | ignores_comments: true 77 | 78 | implicit_return: 79 | included: 80 | - closure 81 | - getter 82 | -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/SmoothLinearGradientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmoothLinearGradientTests.swift 3 | // SmoothGradientTests 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | import SwiftUI 13 | import SnapshotTesting 14 | import SmoothGradient 15 | 16 | #if compiler(>=5.9) 17 | #if os(iOS) 18 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 19 | final class SmoothLinearGradientTests: XCTestCase { 20 | func test_horizontal() { 21 | verify( 22 | SmoothLinearGradient(from: .green, to: .blue, startPoint: .leading, endPoint: .trailing) 23 | ) 24 | } 25 | 26 | func test_vertical() { 27 | verify( 28 | SmoothLinearGradient(from: .green, to: .blue, startPoint: .top, endPoint: .bottom) 29 | ) 30 | } 31 | 32 | func test_diagonal() { 33 | verify( 34 | SmoothLinearGradient(from: .green, to: .blue, startPoint: .topLeading, endPoint: .bottomTrailing) 35 | ) 36 | } 37 | 38 | func test_stops() { 39 | verify( 40 | SmoothLinearGradient( 41 | from: .init(color: .green, location: 0.2), 42 | to: .init(color: .red, location: 0.4), 43 | startPoint: .top, 44 | endPoint: .bottom 45 | ) 46 | ) 47 | } 48 | } 49 | 50 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 51 | extension SmoothLinearGradientTests { 52 | func verify( 53 | _ gradient: SmoothLinearGradient, 54 | file: StaticString = #file, 55 | testName: String = #function, 56 | line: UInt = #line 57 | ) { 58 | assertSnapshot( 59 | matching: gradient.frame(width: 256, height: 256), 60 | as: .image, 61 | file: file, 62 | testName: testName, 63 | line: line 64 | ) 65 | } 66 | } 67 | #endif 68 | #endif 69 | -------------------------------------------------------------------------------- /Tests/SmoothGradientTests/GradientSmoothTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GradientSmoothTests.swift 3 | // SmoothGradientTests 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import XCTest 12 | import SwiftUI 13 | import SnapshotTesting 14 | import SmoothGradient 15 | 16 | #if os(iOS) 17 | final class GradientSmoothTests: XCTestCase { 18 | func test_easing_easeInOut() throws { 19 | verify( 20 | .smooth(from: .black, to: .black.opacity(0), easing: .easeInOut) 21 | ) 22 | } 23 | 24 | func test_easing_easeIn() throws { 25 | verify( 26 | .smooth(from: .black, to: .black.opacity(0), easing: .easeIn) 27 | ) 28 | } 29 | 30 | func test_easing_easeOut() throws { 31 | verify( 32 | .smooth(from: .black, to: .black.opacity(0), easing: .easeOut) 33 | ) 34 | } 35 | 36 | func test_easing_stops() throws { 37 | verify( 38 | .smooth( 39 | from: .init(color: .green, location: 0.2), 40 | to: .init(color: .red, location: 0.4), 41 | easing: .easeInOut 42 | ) 43 | ) 44 | } 45 | } 46 | 47 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 48 | extension GradientSmoothTests { 49 | #if compiler(>=5.9) 50 | func test_curve_easeInOut() throws { 51 | verify( 52 | .smooth(from: .black, to: .black.opacity(0), curve: .easeInOut) 53 | ) 54 | } 55 | 56 | func test_curve_easeIn() throws { 57 | verify( 58 | .smooth(from: .black, to: .black.opacity(0), curve: .easeIn) 59 | ) 60 | } 61 | 62 | func test_curve_easeOut() throws { 63 | verify( 64 | .smooth(from: .black, to: .black.opacity(0), easing: .easeOut) 65 | ) 66 | } 67 | 68 | func test_curve_stops() throws { 69 | verify( 70 | .smooth( 71 | from: .init(color: .green, location: 0.2), 72 | to: .init(color: .red, location: 0.4), 73 | curve: .easeInOut 74 | ) 75 | ) 76 | } 77 | #endif 78 | } 79 | 80 | extension GradientSmoothTests { 81 | func verify( 82 | _ gradient: Gradient, 83 | file: StaticString = #file, 84 | testName: String = #function, 85 | line: UInt = #line 86 | ) { 87 | let rectangle = Rectangle() 88 | .foregroundColor(.clear) 89 | .frame(width: 256, height: 256) 90 | .background( 91 | LinearGradient( 92 | gradient: gradient, 93 | startPoint: .top, 94 | endPoint: .bottom 95 | ) 96 | ) 97 | 98 | assertSnapshot( 99 | matching: rectangle, 100 | as: .image, 101 | file: file, 102 | testName: testName, 103 | line: line 104 | ) 105 | } 106 | } 107 | #endif 108 | -------------------------------------------------------------------------------- /Sources/SmoothGradient/GradientInterpolator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GradientInterpolator.swift 3 | // SmoothGradient 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import SwiftUI 12 | #if canImport(UIKit) 13 | import UIKit 14 | #else 15 | import AppKit 16 | #endif 17 | 18 | struct GradientInterpolator { 19 | #if canImport(UIKit) 20 | /// Alias of UIColor. 21 | private typealias PlatformColor = UIColor 22 | #else 23 | /// Alias of NSColor. 24 | private typealias PlatformColor = NSColor 25 | #endif 26 | 27 | private let curve: Curve 28 | private let color1: PlatformColor 29 | private let color2: PlatformColor 30 | 31 | init(curve: Curve, color1: Color, color2: Color) { 32 | self.curve = curve 33 | self.color1 = PlatformColor(color1) 34 | self.color2 = PlatformColor(color2) 35 | } 36 | 37 | func blend(progress: Double) -> Color { 38 | // Use a dynamic color to ensure that the gradient is updated when the 39 | // system appearance changes. 40 | return Color(Self.makeDynamicColor { 41 | var r1: CGFloat = 0 42 | var g1: CGFloat = 0 43 | var b1: CGFloat = 0 44 | var a1: CGFloat = 0 45 | 46 | var r2: CGFloat = 0 47 | var g2: CGFloat = 0 48 | var b2: CGFloat = 0 49 | var a2: CGFloat = 0 50 | 51 | Self.getRed(&r1, green: &g1, blue: &b1, alpha: &a1, from: color1) 52 | Self.getRed(&r2, green: &g2, blue: &b2, alpha: &a2, from: color2) 53 | 54 | let value = curve.value(at: progress) 55 | 56 | let r = r1 + (r2 - r1) * value 57 | let g = g1 + (g2 - g1) * value 58 | let b = b1 + (b2 - b1) * value 59 | let a = a1 + (a2 - a1) * value 60 | 61 | return .init(red: r, green: g, blue: b, alpha: a) 62 | }) 63 | } 64 | } 65 | 66 | extension GradientInterpolator { 67 | private static func makeDynamicColor(_ block: @escaping () -> PlatformColor) -> PlatformColor { 68 | #if canImport(UIKit) 69 | #if os(watchOS) 70 | // watchOS doesn't support dynamic color providers. We simply invoke 71 | // the block and return the transformed color. 72 | return block() 73 | #else 74 | // iOS, iPadOS, Mac Catalyst, tvOS, and visionOS. 75 | return PlatformColor { _ in block() } 76 | #endif 77 | #else 78 | // macOS 79 | return PlatformColor(name: nil) { _ in block() } 80 | #endif 81 | } 82 | 83 | private static func getRed( 84 | _ red: inout CGFloat, 85 | green: inout CGFloat, 86 | blue: inout CGFloat, 87 | alpha: inout CGFloat, 88 | from color: PlatformColor 89 | ) { 90 | #if canImport(UIKit) 91 | let result = color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) 92 | assert(result, "Failed to get RGBA components from UIColor") 93 | #else 94 | if let rgbColor = color.usingColorSpace(.sRGB) { 95 | rgbColor.getRed( 96 | &red, 97 | green: &green, 98 | blue: &blue, 99 | alpha: &alpha 100 | ) 101 | } else { 102 | assertionFailure("Failed to convert color space") 103 | } 104 | #endif 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/SmoothGradient/SmoothLinearGradient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmoothLinearGradient.swift 3 | // SmoothGradientTests 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import SwiftUI 12 | 13 | #if compiler(>=5.9) 14 | /// A smooth linear gradient. 15 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 16 | public struct SmoothLinearGradient: ShapeStyle, View { 17 | let from: Gradient.Stop 18 | let to: Gradient.Stop 19 | let startPoint: UnitPoint 20 | let endPoint: UnitPoint 21 | let curve: UnitCurve 22 | let steps: Int 23 | 24 | /// Creates a smooth gradient from two colors. 25 | /// 26 | /// - Parameters: 27 | /// - from: The start color. 28 | /// - to: The end color. 29 | /// - startPoint: Origin of the gradient. 30 | /// - endPoint: End point of the gradient. Together with `startPoint` defines the gradient's direction. 31 | /// - curve: Easing curve to use. 32 | /// - steps: Number of steps to use when generating the gradient. Defaults to 16. 33 | public init( 34 | from: Color, 35 | to: Color, 36 | startPoint: UnitPoint, 37 | endPoint: UnitPoint, 38 | curve: UnitCurve = .easeInOut, 39 | steps: Int = 16 40 | ) { 41 | self.init( 42 | from: Gradient.Stop(color: from, location: 0), 43 | to: Gradient.Stop(color: to, location: 1), 44 | startPoint: startPoint, 45 | endPoint: endPoint, 46 | curve: curve, 47 | steps: steps 48 | ) 49 | } 50 | 51 | /// Creates a smooth gradient from two color stops. 52 | /// 53 | /// - Parameters: 54 | /// - from: The start color. 55 | /// - to: The end color. 56 | /// - startPoint: Origin of the gradient. 57 | /// - endPoint: End point of the gradient. Together with `startPoint` defines the gradient's direction. 58 | /// - curve: Easing curve to use. 59 | /// - steps: Number of steps to use when generating the gradient. Defaults to 16. 60 | public init( 61 | from: Gradient.Stop, 62 | to: Gradient.Stop, 63 | startPoint: UnitPoint, 64 | endPoint: UnitPoint, 65 | curve: UnitCurve = .easeInOut, 66 | steps: Int = 16 67 | ) { 68 | self.from = from 69 | self.to = to 70 | self.startPoint = startPoint 71 | self.endPoint = endPoint 72 | self.curve = curve 73 | self.steps = steps 74 | } 75 | 76 | public func resolve(in environment: EnvironmentValues) -> LinearGradient { 77 | LinearGradient( 78 | gradient: .smooth(from: from, to: to, curve: curve, steps: steps), 79 | startPoint: startPoint, 80 | endPoint: endPoint 81 | ) 82 | } 83 | } 84 | 85 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 86 | struct SmoothLinearGradient_Previews: PreviewProvider { 87 | static var previews: some View { 88 | SmoothLinearGradient( 89 | from: .green, 90 | to: .blue, 91 | startPoint: .leading, 92 | endPoint: .trailing, 93 | curve: .easeInOut 94 | ) 95 | .ignoresSafeArea() 96 | .previewDisplayName("Colors") 97 | 98 | SmoothLinearGradient( 99 | from: .init(color: .black, location: 0), 100 | to: .init(color: .black.opacity(0), location: 0.75), 101 | startPoint: .top, 102 | endPoint: .bottom, 103 | curve: .easeInOut 104 | ) 105 | .ignoresSafeArea() 106 | .previewDisplayName("Stops") 107 | } 108 | } 109 | #endif 110 | -------------------------------------------------------------------------------- /Sources/SmoothGradient/CubicBezierCurve.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CubicBezierCurve.swift 3 | // SmoothGradient 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import SwiftUI 12 | 13 | /// Cubic Bezier curve for interpolating the gradient. 14 | /// 15 | /// A Cubic Bezier is defined by four points: p0, p1, ..., p3). For our purpose, 16 | /// p0 and p3 are always fixed at (0, 0) and (1, 1), respectively. 17 | @available(iOS, introduced: 14.0, deprecated: 17.0, message: "use UnitCurve instead") 18 | @available(macOS, introduced: 11.0, deprecated: 14.0, message: "use UnitCurve instead") 19 | @available(tvOS, introduced: 14.0, deprecated: 17.0, message: "use UnitCurve instead") 20 | @available(watchOS, introduced: 7.0, deprecated: 10.0, message: "use UnitCurve instead") 21 | @available(visionOS, deprecated: 1.0, message: "use UnitCurve instead") 22 | public struct CubicBezierCurve: Curve { 23 | let p1: UnitPoint 24 | let p2: UnitPoint 25 | 26 | let a: CGPoint 27 | let b: CGPoint 28 | let c: CGPoint 29 | 30 | /// A bezier curve that starts out slowly, then speeds up as it finishes. 31 | public static let easeIn = CubicBezierCurve( 32 | p1: UnitPoint(x: 0.42, y: 0), 33 | p2: UnitPoint(x: 1, y: 1) 34 | ) 35 | 36 | /// A bezier curve that starts out quickly, then slows down as it approaches the end. 37 | public static let easeOut = CubicBezierCurve( 38 | p1: UnitPoint(x: 0, y: 0), 39 | p2: UnitPoint(x: 0.58, y: 1) 40 | ) 41 | 42 | /// A bezier curve that starts out slowly, speeds up over the middle, then slows down again as it approaches the end. 43 | public static let easeInOut = CubicBezierCurve( 44 | p1: UnitPoint(x: 0.42, y: 0), 45 | p2: UnitPoint(x: 0.58, y: 1) 46 | ) 47 | 48 | /// Creates a new Cubic Bezier curve with the given control points. 49 | /// 50 | /// - Parameters: 51 | /// - p1: Control point 1. 52 | /// - p2: Control point 2. 53 | public init(p1: UnitPoint, p2: UnitPoint) { 54 | self.p1 = p1 55 | self.p2 = p2 56 | 57 | // Calculate coefficients 58 | self.c = CGPoint(x: 3 * p1.x, y: 3 * p1.y) 59 | self.b = CGPoint(x: 3 * (p2.x - p1.x) - c.x, y: 3 * (p2.y - p1.y) - c.y) 60 | self.a = CGPoint(x: 1 - c.x - b.x, y: 1 - c.y - b.y) 61 | } 62 | 63 | func value(at x: Double) -> Double { 64 | guard p1 != p2 else { return x } 65 | return sampleCurveY(getT(x: x)) 66 | } 67 | 68 | private func getT(x: Double) -> Double { 69 | assert(x >= 0 && x <= 1, "x must be between 0 and 1") 70 | 71 | var guessT = x 72 | 73 | // Newton's method to approximate T 74 | for _ in 0..<8 { 75 | let currentSlope = sampleCurveDerivativeX(guessT) 76 | if currentSlope == 0 { 77 | return guessT 78 | } 79 | 80 | let currentX = sampleCurveX(guessT) - x 81 | guessT -= currentX / currentSlope 82 | } 83 | 84 | return guessT 85 | } 86 | 87 | private func sampleCurveX(_ t: Double) -> Double { 88 | return ((a.x * t + b.x) * t + c.x) * t 89 | } 90 | 91 | private func sampleCurveY(_ t: Double) -> Double { 92 | return ((a.y * t + b.y) * t + c.y) * t 93 | } 94 | 95 | private func sampleCurveDerivativeX(_ t: Double) -> Double { 96 | return (3 * a.x * t + 2 * b.x) * t + c.x 97 | } 98 | } 99 | 100 | // MARK: - Preview 101 | 102 | #if compiler(>=5.9) 103 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 104 | struct CubicBezierCurve_Previews: PreviewProvider { 105 | static var previews: some View { 106 | Canvas(opaque: true) { context, size in 107 | context.withCGContext { ctx in 108 | func plot( 109 | color: CGColor, 110 | dashed: Bool = false, 111 | function: (Double) -> Double 112 | ) { 113 | let steps = 16 114 | 115 | ctx.beginPath() 116 | ctx.move(to: CGPoint(x: 0, y: size.height)) 117 | for step in stride(from: 0, to: steps, by: 1) { 118 | let x = Double(step) / Double(steps - 1) 119 | let y = 1 - function(x) 120 | ctx.addLine(to: CGPoint(x: x * size.width, y: y * size.height)) 121 | } 122 | 123 | ctx.setLineWidth(3) 124 | ctx.setStrokeColor(color) 125 | if dashed { 126 | ctx.setLineDash( 127 | phase: 0, 128 | lengths: [size.width / CGFloat(steps * 2)] 129 | ) 130 | } 131 | 132 | ctx.strokePath() 133 | } 134 | 135 | ctx.beginPath() 136 | ctx.addRect(CGRect(origin: .zero, size: size)) 137 | ctx.setFillColor(CGColor(gray: 1, alpha: 1)) 138 | ctx.fillPath() 139 | 140 | plot( 141 | color: CGColor(red: 1, green: 0, blue: 0, alpha: 1) 142 | ) { progress in 143 | CubicBezierCurve.easeInOut.value(at: progress) 144 | } 145 | 146 | plot( 147 | color: CGColor(red: 0, green: 1, blue: 0, alpha: 1), 148 | dashed: true 149 | ) { progress in 150 | UnitCurve.easeInOut.value(at: progress) 151 | } 152 | } 153 | } 154 | .frame(width: 400, height: 400) 155 | } 156 | } 157 | #endif 158 | -------------------------------------------------------------------------------- /Sources/SmoothGradient/Gradient+Smooth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Gradient+Smooth.swift 3 | // SmoothGradient 4 | // 5 | // Copyright (c) 2023-2024 Ramon Torres 6 | // 7 | // This file is part of SmoothGradient which is released under the MIT license. 8 | // See the LICENSE file in the root directory of this source tree for full details. 9 | // 10 | 11 | import SwiftUI 12 | 13 | // MARK: - iOS 17 14 | 15 | #if compiler(>=5.9) 16 | extension Gradient { 17 | /// Creates a gradient with the given easing function. 18 | /// 19 | /// - Parameters: 20 | /// - from: The start color. 21 | /// - to: The end color. 22 | /// - curve: The easing function to use. 23 | /// - steps: The number of steps to use when generating the gradient. Defaults to 16. 24 | /// - Returns: A gradient. 25 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 26 | public static func smooth( 27 | from: Color, 28 | to: Color, 29 | curve: UnitCurve = .easeInOut, 30 | steps: Int = 16 31 | ) -> Gradient { 32 | return makeSmoothGradient( 33 | from: Stop(color: from, location: 0), 34 | to: Stop(color: to, location: 1), 35 | curve: curve, 36 | steps: steps 37 | ) 38 | } 39 | 40 | /// Creates a gradient with the given easing function. 41 | /// 42 | /// - Parameters: 43 | /// - from: The start color. 44 | /// - to: The end color. 45 | /// - curve: The easing function to use. 46 | /// - steps: The number of steps to use when generating the gradient. Defaults to 16. 47 | /// - Returns: A gradient. 48 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) 49 | public static func smooth( 50 | from: Stop, 51 | to: Stop, 52 | curve: UnitCurve = .easeInOut, 53 | steps: Int = 16 54 | ) -> Gradient { 55 | return makeSmoothGradient( 56 | from: from, 57 | to: to, 58 | curve: curve, 59 | steps: steps 60 | ) 61 | } 62 | } 63 | #endif 64 | 65 | // MARK: - Pre iOS 17 66 | 67 | extension Gradient { 68 | /// Creates a gradient with the given easing function. 69 | /// 70 | /// - Parameters: 71 | /// - from: The start color. 72 | /// - to: The end color. 73 | /// - curve: The easing function to use. 74 | /// - steps: The number of steps to use when generating the gradient. Defaults to 16. 75 | /// - Returns: A gradient. 76 | @available(iOS, introduced: 14.0, deprecated: 17.0, renamed: "smooth(from:to:curve:steps:)") 77 | @available(macOS, introduced: 11.0, deprecated: 14.0, renamed: "smooth(from:to:curve:steps:)") 78 | @available(tvOS, introduced: 14.0, deprecated: 17.0, renamed: "smooth(from:to:curve:steps:)") 79 | @available(watchOS, introduced: 7.0, deprecated: 10.0, renamed: "smooth(from:to:curve:steps:)") 80 | @available(visionOS, deprecated: 1.0, renamed: "smooth(from:to:curve:steps:)") 81 | @_disfavoredOverload 82 | public static func smooth( 83 | from: Color, 84 | to: Color, 85 | easing curve: CubicBezierCurve = .easeInOut, 86 | steps: Int = 16 87 | ) -> Gradient { 88 | return smooth( 89 | from: Stop(color: from, location: 0), 90 | to: Stop(color: to, location: 1), 91 | easing: curve, 92 | steps: steps 93 | ) 94 | } 95 | 96 | /// Creates a gradient with the given easing function. 97 | /// 98 | /// - Parameters: 99 | /// - from: The start color. 100 | /// - to: The end color. 101 | /// - curve: The easing function to use. 102 | /// - steps: The number of steps to use when generating the gradient. Defaults to 16. 103 | /// - Returns: A gradient. 104 | @available(iOS, introduced: 14.0, deprecated: 17.0, renamed: "smooth(from:to:curve:steps:)") 105 | @available(macOS, introduced: 11.0, deprecated: 14.0, renamed: "smooth(from:to:curve:steps:)") 106 | @available(tvOS, introduced: 14.0, deprecated: 17.0, renamed: "smooth(from:to:curve:steps:)") 107 | @available(watchOS, introduced: 7.0, deprecated: 10.0, renamed: "smooth(from:to:curve:steps:)") 108 | @available(visionOS, deprecated: 1.0, renamed: "smooth(from:to:curve:steps:)") 109 | @_disfavoredOverload 110 | public static func smooth( 111 | from: Stop, 112 | to: Stop, 113 | easing curve: CubicBezierCurve = .easeInOut, 114 | steps: Int = 16 115 | ) -> Gradient { 116 | return makeSmoothGradient( 117 | from: from, 118 | to: to, 119 | curve: curve, 120 | steps: steps 121 | ) 122 | } 123 | } 124 | 125 | // MARK: - Factory 126 | 127 | extension Gradient { 128 | private static func makeSmoothGradient( 129 | from: Stop, 130 | to: Stop, 131 | curve: Curve, 132 | steps: Int 133 | ) -> Gradient { 134 | let ramp = stride(from: 0, to: steps, by: 1).lazy.map { index in 135 | Double(index) / Double(steps - 1) 136 | } 137 | 138 | let interpolator = GradientInterpolator( 139 | curve: curve, 140 | color1: from.color, 141 | color2: to.color 142 | ) 143 | 144 | return Gradient(stops: ramp.map { progress in 145 | Stop( 146 | color: interpolator.blend(progress: progress), 147 | location: from.location + progress * (to.location - from.location) 148 | ) 149 | }) 150 | } 151 | } 152 | 153 | // MARK: - Preview 154 | 155 | struct Gradient_Previews: PreviewProvider { 156 | static var previews: some View { 157 | VStack { 158 | LinearGradient( 159 | gradient: .smooth( 160 | from: .black, 161 | to: .black.opacity(0), 162 | easing: .easeInOut 163 | ), 164 | startPoint: .top, 165 | endPoint: .bottom 166 | ) 167 | .frame(height: 200) 168 | Spacer() 169 | } 170 | .background(Color.white, alignment: .center) 171 | } 172 | } 173 | --------------------------------------------------------------------------------