diff --git a/0.x.x to 1.0.0 Update Guide/README.md b/0.x.x to 1.0.0 Update Guide/README.md new file mode 100644 index 0000000..3484f0f --- /dev/null +++ b/0.x.x to 1.0.0 Update Guide/README.md @@ -0,0 +1,61 @@ +# 1.0 Update Guide + +If you are moving from a version of Pow below 1.0.0 the first thing you'll notice is that Pow is now open source. 🎉🎉🎉 + +Previously Pow was a paid product, and now it is a a community project operated and sponsored by [Emerge Tools](https://github.com/EmergeTools). + +--- + +> [!NOTE] +> Pow's version number has been bumped from 0.3.1 to 1.0.0, and despite this being a new major version there are **no breaking changes**. +> +> Now that the Pow project is run by EmergeTools you should use the URL https://github.com/EmergeTools/Pow rather than https://github.com/movingparts-io/Pow. + + +### Integrate Pow 1.0.0+ into your app. + +If you've integrated Pow through Xcode's Package Dependencies you will need to update the Pow package dependency to point to 1.0.0. + +If you're using the default Up to Next Major Version rule you may need to manually update the version number to 1.0.0. Going from 0.x.x to 1.0.0 is considered a major version update, so Xcode will not do it automatically on your behalf for fear of breaking changes. + +![](./images/pow-version-updated-before.png) + +When you set Pow to version 1.0.0 in your Package Dependencies list it will now look like this. + +![](./images/pow-version-updated-after.png) + +If you're using Swift Package Manager you should update the URL and version number of any references you have to Pow. + +> Before +> ```swift +> .package(url: "https://github.com/movingparts-io/Pow", from: Version(0, 3, 1)) +> ``` + +> After +> ```swift +> .package(url: "https://github.com/EmergeTools/Pow", from: Version(1, 0, 0)) +> ``` + +Sometimes Swift Package Manager will show errors like this after upgrading a dependency. + +![](./images/xcode-errors.png) + +This is a long-standing issue with Xcode, not Pow. The solution of course is to close and re-open Xcode. + +![](./images/pow-source-after-update.png) + +To confirm that Pow has been updated you can look at the list of installed Swift Packages in your project's File Navigator. (The first tab of Xcode's left sidebar.) If everything has gone correctly you will see Pow 1.0.0, and now that the framework is open source you will also see all of the source code. + +--- + +### Remove Pow's License + +Now that Pow is free, you too are free to remove this line of code that would validate your purchase of a Pow license. + +```swift +Pow.unlockPow(reason: .iDidBuyTheLicense) +``` + +--- + +And that's it, easy as 0.1, 0.2, 0.3. If you run into any problems upgrading please file an [issue](gihtub.com/EmergeTools/Pow/issues), we're more than happy to help. \ No newline at end of file diff --git a/0.x.x to 1.0.0 Update Guide/images/pow-source-after-update.png b/0.x.x to 1.0.0 Update Guide/images/pow-source-after-update.png new file mode 100644 index 0000000..ba74703 Binary files /dev/null and b/0.x.x to 1.0.0 Update Guide/images/pow-source-after-update.png differ diff --git a/0.x.x to 1.0.0 Update Guide/images/pow-version-updated-after.png b/0.x.x to 1.0.0 Update Guide/images/pow-version-updated-after.png new file mode 100644 index 0000000..c3ac035 Binary files /dev/null and b/0.x.x to 1.0.0 Update Guide/images/pow-version-updated-after.png differ diff --git a/0.x.x to 1.0.0 Update Guide/images/pow-version-updated-before.png b/0.x.x to 1.0.0 Update Guide/images/pow-version-updated-before.png new file mode 100644 index 0000000..cb9d64a Binary files /dev/null and b/0.x.x to 1.0.0 Update Guide/images/pow-version-updated-before.png differ diff --git a/0.x.x to 1.0.0 Update Guide/images/xcode-errors.png b/0.x.x to 1.0.0 Update Guide/images/xcode-errors.png new file mode 100644 index 0000000..41d9b44 Binary files /dev/null and b/0.x.x to 1.0.0 Update Guide/images/xcode-errors.png differ diff --git a/Example/Pow Example.xcodeproj/project.pbxproj b/Example/Pow Example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..88747fa --- /dev/null +++ b/Example/Pow Example.xcodeproj/project.pbxproj @@ -0,0 +1,790 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 236B13462940BAFD0022AF1F /* CheckoutExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236B13452940BAFD0022AF1F /* CheckoutExample.swift */; }; + 541356DC293E91A000EC0F1A /* SoundEffectsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541356DB293E91A000EC0F1A /* SoundEffectsExample.swift */; }; + 54135714293E941000EC0F1A /* pick.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135708293E93D600EC0F1A /* pick.m4a */; }; + 54135715293E941000EC0F1A /* reel.falling.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E4293E93D400EC0F1A /* reel.falling.m4a */; }; + 54135716293E941000EC0F1A /* latch1.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356EA293E93D400EC0F1A /* latch1.m4a */; }; + 54135717293E941000EC0F1A /* pick.falling.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E5293E93D400EC0F1A /* pick.falling.m4a */; }; + 54135718293E941000EC0F1A /* brush.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135704293E93D600EC0F1A /* brush.m4a */; }; + 54135719293E941000EC0F1A /* pop4.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356FF293E93D600EC0F1A /* pop4.m4a */; }; + 5413571A293E941000EC0F1A /* reel.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356DF293E93D400EC0F1A /* reel.m4a */; }; + 5413571B293E941000EC0F1A /* sparkle.rising.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F9293E93D500EC0F1A /* sparkle.rising.m4a */; }; + 5413571C293E941000EC0F1A /* wip.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356EB293E93D400EC0F1A /* wip.m4a */; }; + 5413571D293E941000EC0F1A /* tock.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F0293E93D500EC0F1A /* tock.m4a */; }; + 5413571E293E941000EC0F1A /* pick.flat.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356ED293E93D400EC0F1A /* pick.flat.m4a */; }; + 5413571F293E941000EC0F1A /* chime.falling.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5413570F293E93D700EC0F1A /* chime.falling.m4a */; }; + 54135720293E941000EC0F1A /* chime.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F7293E93D500EC0F1A /* chime.m4a */; }; + 54135721293E941000EC0F1A /* swipe.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135709293E93D600EC0F1A /* swipe.m4a */; }; + 54135722293E941000EC0F1A /* tick.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135705293E93D600EC0F1A /* tick.m4a */; }; + 54135723293E941000EC0F1A /* lock1.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135701293E93D600EC0F1A /* lock1.m4a */; }; + 54135724293E941000EC0F1A /* pick.rising.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135706293E93D600EC0F1A /* pick.rising.m4a */; }; + 54135725293E941000EC0F1A /* plop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F6293E93D500EC0F1A /* plop.m4a */; }; + 54135726293E941000EC0F1A /* detach.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135710293E93D700EC0F1A /* detach.m4a */; }; + 54135727293E941000EC0F1A /* swish.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356FA293E93D500EC0F1A /* swish.m4a */; }; + 54135728293E941000EC0F1A /* snap.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135707293E93D600EC0F1A /* snap.m4a */; }; + 54135729293E941000EC0F1A /* drip.falling.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5413570C293E93D600EC0F1A /* drip.falling.m4a */; }; + 5413572A293E941000EC0F1A /* chime.rising.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E3293E93D400EC0F1A /* chime.rising.m4a */; }; + 5413572B293E941000EC0F1A /* notfound.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356EC293E93D400EC0F1A /* notfound.m4a */; }; + 5413572C293E941000EC0F1A /* pluck.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5413570E293E93D600EC0F1A /* pluck.m4a */; }; + 5413572D293E941000EC0F1A /* dial.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E9293E93D400EC0F1A /* dial.m4a */; }; + 5413572E293E941000EC0F1A /* shake.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E7293E93D400EC0F1A /* shake.m4a */; }; + 5413572F293E941000EC0F1A /* tink.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F1293E93D500EC0F1A /* tink.m4a */; }; + 54135730293E941000EC0F1A /* lock4.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F2293E93D500EC0F1A /* lock4.m4a */; }; + 54135731293E941000EC0F1A /* boop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356FB293E93D500EC0F1A /* boop.m4a */; }; + 54135732293E941000EC0F1A /* drip.flat.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135713293E93D700EC0F1A /* drip.flat.m4a */; }; + 54135733293E941000EC0F1A /* latch2.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135702293E93D600EC0F1A /* latch2.m4a */; }; + 54135734293E941000EC0F1A /* lock2.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E1293E93D400EC0F1A /* lock2.m4a */; }; + 54135735293E941000EC0F1A /* pop5.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F5293E93D500EC0F1A /* pop5.m4a */; }; + 54135736293E941000EC0F1A /* latch4.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E8293E93D400EC0F1A /* latch4.m4a */; }; + 54135737293E941000EC0F1A /* glass.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135700293E93D600EC0F1A /* glass.m4a */; }; + 54135738293E941000EC0F1A /* zing.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356EE293E93D400EC0F1A /* zing.m4a */; }; + 54135739293E941000EC0F1A /* pong.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E2293E93D400EC0F1A /* pong.m4a */; }; + 5413573A293E941000EC0F1A /* drip.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135711293E93D700EC0F1A /* drip.m4a */; }; + 5413573B293E941000EC0F1A /* beep.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356FC293E93D500EC0F1A /* beep.m4a */; }; + 5413573C293E941000EC0F1A /* latch3.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F3293E93D500EC0F1A /* latch3.m4a */; }; + 5413573D293E941000EC0F1A /* reel.rising.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5413570D293E93D600EC0F1A /* reel.rising.m4a */; }; + 5413573E293E941000EC0F1A /* sparkle.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5413570A293E93D600EC0F1A /* sparkle.m4a */; }; + 5413573F293E941000EC0F1A /* sparkle.flat.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356FD293E93D500EC0F1A /* sparkle.flat.m4a */; }; + 54135740293E941000EC0F1A /* reel.flat.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356DE293E93D300EC0F1A /* reel.flat.m4a */; }; + 54135741293E941000EC0F1A /* pop2.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135703293E93D600EC0F1A /* pop2.m4a */; }; + 54135742293E941000EC0F1A /* chime.flat.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356EF293E93D500EC0F1A /* chime.flat.m4a */; }; + 54135743293E941000EC0F1A /* whop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356FE293E93D500EC0F1A /* whop.m4a */; }; + 54135744293E941000EC0F1A /* drip.rising.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5413570B293E93D600EC0F1A /* drip.rising.m4a */; }; + 54135745293E941000EC0F1A /* ping.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F8293E93D500EC0F1A /* ping.m4a */; }; + 54135746293E941000EC0F1A /* lock3.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356F4293E93D500EC0F1A /* lock3.m4a */; }; + 54135747293E941000EC0F1A /* pop1.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 54135712293E93D700EC0F1A /* pop1.m4a */; }; + 54135748293E941000EC0F1A /* sparkle.falling.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E6293E93D400EC0F1A /* sparkle.falling.m4a */; }; + 54135749293E941000EC0F1A /* pop3.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356E0293E93D400EC0F1A /* pop3.m4a */; }; + 5413574A293E941000EC0F1A /* biip.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 541356DD293E93D300EC0F1A /* biip.m4a */; }; + 546A298A292A31BB00A80DE2 /* PowExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A2989292A31BB00A80DE2 /* PowExampleApp.swift */; }; + 546A298C292A31BB00A80DE2 /* ExampleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A298B292A31BB00A80DE2 /* ExampleList.swift */; }; + 546A298E292A31BC00A80DE2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 546A298D292A31BC00A80DE2 /* Assets.xcassets */; }; + 546A2991292A31BC00A80DE2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 546A2990292A31BC00A80DE2 /* Preview Assets.xcassets */; }; + 546A299B292A332900A80DE2 /* AnvilExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A299A292A332900A80DE2 /* AnvilExample.swift */; }; + 546A299E292A335600A80DE2 /* PlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A299D292A335600A80DE2 /* PlaceholderView.swift */; }; + 546A29A0292A341700A80DE2 /* Example.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A299F292A341700A80DE2 /* Example.swift */; }; + 546A29A2292A349900A80DE2 /* BlindsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29A1292A349900A80DE2 /* BlindsExample.swift */; }; + 546A29A4292A34A600A80DE2 /* ClockExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29A3292A34A600A80DE2 /* ClockExample.swift */; }; + 546A29A6292A355400A80DE2 /* BoingExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29A5292A355400A80DE2 /* BoingExample.swift */; }; + 546A29AA292A397100A80DE2 /* BlurExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29A9292A397100A80DE2 /* BlurExample.swift */; }; + 546A29AC292A3B9D00A80DE2 /* FilmExposureExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29AB292A3B9D00A80DE2 /* FilmExposureExample.swift */; }; + 546A29AE292A3D8900A80DE2 /* FlipExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29AD292A3D8900A80DE2 /* FlipExample.swift */; }; + 546A29B0292A3DA800A80DE2 /* FlickerExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29AF292A3DA800A80DE2 /* FlickerExample.swift */; }; + 546A29B2292A3ED800A80DE2 /* GlareExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29B1292A3ED800A80DE2 /* GlareExample.swift */; }; + 546A29B4292A401500A80DE2 /* PopExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29B3292A401500A80DE2 /* PopExample.swift */; }; + 546A29B6292A406E00A80DE2 /* IrisExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29B5292A406E00A80DE2 /* IrisExample.swift */; }; + 546A29B8292A40B000A80DE2 /* MoveExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29B7292A40AF00A80DE2 /* MoveExample.swift */; }; + 546A29BA292A40D800A80DE2 /* PoofExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29B9292A40D800A80DE2 /* PoofExample.swift */; }; + 546A29BC292A414A00A80DE2 /* SnapshotExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29BB292A414A00A80DE2 /* SnapshotExample.swift */; }; + 546A29BE292A41CF00A80DE2 /* VanishExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29BD292A41CF00A80DE2 /* VanishExample.swift */; }; + 546A29C0292A420F00A80DE2 /* SwooshExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29BF292A420E00A80DE2 /* SwooshExample.swift */; }; + 546A29C2292A431800A80DE2 /* WipeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29C1292A431800A80DE2 /* WipeExample.swift */; }; + 546A29C6292A4BC100A80DE2 /* JumpExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29C5292A4BC100A80DE2 /* JumpExample.swift */; }; + 546A29C8292A4BFA00A80DE2 /* PingExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29C7292A4BFA00A80DE2 /* PingExample.swift */; }; + 546A29CA292A4DBD00A80DE2 /* RiseExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29C9292A4DBD00A80DE2 /* RiseExample.swift */; }; + 546A29CC292A559A00A80DE2 /* ShakeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29CB292A559A00A80DE2 /* ShakeExample.swift */; }; + 546A29CE292A591F00A80DE2 /* ShineExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29CD292A591F00A80DE2 /* ShineExample.swift */; }; + 546A29D0292A676200A80DE2 /* SkidExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29CF292A676200A80DE2 /* SkidExample.swift */; }; + 546A29D2292A68E100A80DE2 /* SpinExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29D1292A68E100A80DE2 /* SpinExample.swift */; }; + 546A29D4292A6B1200A80DE2 /* SprayExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 546A29D3292A6B1200A80DE2 /* SprayExample.swift */; }; + 549BF5F5293642BC00B2DCCF /* SocialFeedExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549BF5F4293642BC00B2DCCF /* SocialFeedExample.swift */; }; + 54A962302934CC4400BBD5FE /* GithubButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54A9622F2934CC4400BBD5FE /* GithubButton.swift */; }; + 54B7259729F9C70900C17B99 /* PushDownExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7259629F9C70900C17B99 /* PushDownExample.swift */; }; + 54F15E5029D598150054690B /* RepeatExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F15E4F29D598150054690B /* RepeatExample.swift */; }; + 54F15E5229D59A920054690B /* SmokeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F15E5129D59A920054690B /* SmokeExample.swift */; }; + 54F15E5429D59D250054690B /* PulseExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F15E5329D59D250054690B /* PulseExample.swift */; }; + 54F15E5629D5A0D70054690B /* GlowExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F15E5529D5A0D70054690B /* GlowExample.swift */; }; + B7A5DA8D2B0E853A0035FC0A /* Pow in Frameworks */ = {isa = PBXBuildFile; productRef = B7A5DA8C2B0E853A0035FC0A /* Pow */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 236B13452940BAFD0022AF1F /* CheckoutExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutExample.swift; sourceTree = ""; }; + 541356DB293E91A000EC0F1A /* SoundEffectsExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEffectsExample.swift; sourceTree = ""; }; + 541356DD293E93D300EC0F1A /* biip.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = biip.m4a; sourceTree = ""; }; + 541356DE293E93D300EC0F1A /* reel.flat.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = reel.flat.m4a; sourceTree = ""; }; + 541356DF293E93D400EC0F1A /* reel.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = reel.m4a; sourceTree = ""; }; + 541356E0293E93D400EC0F1A /* pop3.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pop3.m4a; sourceTree = ""; }; + 541356E1293E93D400EC0F1A /* lock2.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = lock2.m4a; sourceTree = ""; }; + 541356E2293E93D400EC0F1A /* pong.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pong.m4a; sourceTree = ""; }; + 541356E3293E93D400EC0F1A /* chime.rising.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = chime.rising.m4a; sourceTree = ""; }; + 541356E4293E93D400EC0F1A /* reel.falling.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = reel.falling.m4a; sourceTree = ""; }; + 541356E5293E93D400EC0F1A /* pick.falling.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pick.falling.m4a; sourceTree = ""; }; + 541356E6293E93D400EC0F1A /* sparkle.falling.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sparkle.falling.m4a; sourceTree = ""; }; + 541356E7293E93D400EC0F1A /* shake.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = shake.m4a; sourceTree = ""; }; + 541356E8293E93D400EC0F1A /* latch4.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = latch4.m4a; sourceTree = ""; }; + 541356E9293E93D400EC0F1A /* dial.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = dial.m4a; sourceTree = ""; }; + 541356EA293E93D400EC0F1A /* latch1.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = latch1.m4a; sourceTree = ""; }; + 541356EB293E93D400EC0F1A /* wip.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = wip.m4a; sourceTree = ""; }; + 541356EC293E93D400EC0F1A /* notfound.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = notfound.m4a; sourceTree = ""; }; + 541356ED293E93D400EC0F1A /* pick.flat.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pick.flat.m4a; sourceTree = ""; }; + 541356EE293E93D400EC0F1A /* zing.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = zing.m4a; sourceTree = ""; }; + 541356EF293E93D500EC0F1A /* chime.flat.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = chime.flat.m4a; sourceTree = ""; }; + 541356F0293E93D500EC0F1A /* tock.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = tock.m4a; sourceTree = ""; }; + 541356F1293E93D500EC0F1A /* tink.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = tink.m4a; sourceTree = ""; }; + 541356F2293E93D500EC0F1A /* lock4.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = lock4.m4a; sourceTree = ""; }; + 541356F3293E93D500EC0F1A /* latch3.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = latch3.m4a; sourceTree = ""; }; + 541356F4293E93D500EC0F1A /* lock3.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = lock3.m4a; sourceTree = ""; }; + 541356F5293E93D500EC0F1A /* pop5.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pop5.m4a; sourceTree = ""; }; + 541356F6293E93D500EC0F1A /* plop.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = plop.m4a; sourceTree = ""; }; + 541356F7293E93D500EC0F1A /* chime.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = chime.m4a; sourceTree = ""; }; + 541356F8293E93D500EC0F1A /* ping.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = ping.m4a; sourceTree = ""; }; + 541356F9293E93D500EC0F1A /* sparkle.rising.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sparkle.rising.m4a; sourceTree = ""; }; + 541356FA293E93D500EC0F1A /* swish.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = swish.m4a; sourceTree = ""; }; + 541356FB293E93D500EC0F1A /* boop.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = boop.m4a; sourceTree = ""; }; + 541356FC293E93D500EC0F1A /* beep.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = beep.m4a; sourceTree = ""; }; + 541356FD293E93D500EC0F1A /* sparkle.flat.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sparkle.flat.m4a; sourceTree = ""; }; + 541356FE293E93D500EC0F1A /* whop.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = whop.m4a; sourceTree = ""; }; + 541356FF293E93D600EC0F1A /* pop4.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pop4.m4a; sourceTree = ""; }; + 54135700293E93D600EC0F1A /* glass.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = glass.m4a; sourceTree = ""; }; + 54135701293E93D600EC0F1A /* lock1.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = lock1.m4a; sourceTree = ""; }; + 54135702293E93D600EC0F1A /* latch2.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = latch2.m4a; sourceTree = ""; }; + 54135703293E93D600EC0F1A /* pop2.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pop2.m4a; sourceTree = ""; }; + 54135704293E93D600EC0F1A /* brush.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = brush.m4a; sourceTree = ""; }; + 54135705293E93D600EC0F1A /* tick.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = tick.m4a; sourceTree = ""; }; + 54135706293E93D600EC0F1A /* pick.rising.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pick.rising.m4a; sourceTree = ""; }; + 54135707293E93D600EC0F1A /* snap.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = snap.m4a; sourceTree = ""; }; + 54135708293E93D600EC0F1A /* pick.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pick.m4a; sourceTree = ""; }; + 54135709293E93D600EC0F1A /* swipe.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = swipe.m4a; sourceTree = ""; }; + 5413570A293E93D600EC0F1A /* sparkle.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sparkle.m4a; sourceTree = ""; }; + 5413570B293E93D600EC0F1A /* drip.rising.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = drip.rising.m4a; sourceTree = ""; }; + 5413570C293E93D600EC0F1A /* drip.falling.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = drip.falling.m4a; sourceTree = ""; }; + 5413570D293E93D600EC0F1A /* reel.rising.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = reel.rising.m4a; sourceTree = ""; }; + 5413570E293E93D600EC0F1A /* pluck.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pluck.m4a; sourceTree = ""; }; + 5413570F293E93D700EC0F1A /* chime.falling.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = chime.falling.m4a; sourceTree = ""; }; + 54135710293E93D700EC0F1A /* detach.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = detach.m4a; sourceTree = ""; }; + 54135711293E93D700EC0F1A /* drip.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = drip.m4a; sourceTree = ""; }; + 54135712293E93D700EC0F1A /* pop1.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = pop1.m4a; sourceTree = ""; }; + 54135713293E93D700EC0F1A /* drip.flat.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = drip.flat.m4a; sourceTree = ""; }; + 546A2986292A31BB00A80DE2 /* Pow Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pow Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 546A2989292A31BB00A80DE2 /* PowExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowExampleApp.swift; sourceTree = ""; }; + 546A298B292A31BB00A80DE2 /* ExampleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleList.swift; sourceTree = ""; }; + 546A298D292A31BC00A80DE2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 546A2990292A31BC00A80DE2 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 546A299A292A332900A80DE2 /* AnvilExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnvilExample.swift; sourceTree = ""; }; + 546A299D292A335600A80DE2 /* PlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderView.swift; sourceTree = ""; }; + 546A299F292A341700A80DE2 /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = ""; }; + 546A29A1292A349900A80DE2 /* BlindsExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindsExample.swift; sourceTree = ""; }; + 546A29A3292A34A600A80DE2 /* ClockExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClockExample.swift; sourceTree = ""; }; + 546A29A5292A355400A80DE2 /* BoingExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoingExample.swift; sourceTree = ""; }; + 546A29A9292A397100A80DE2 /* BlurExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurExample.swift; sourceTree = ""; }; + 546A29AB292A3B9D00A80DE2 /* FilmExposureExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilmExposureExample.swift; sourceTree = ""; }; + 546A29AD292A3D8900A80DE2 /* FlipExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlipExample.swift; sourceTree = ""; }; + 546A29AF292A3DA800A80DE2 /* FlickerExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlickerExample.swift; sourceTree = ""; }; + 546A29B1292A3ED800A80DE2 /* GlareExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlareExample.swift; sourceTree = ""; }; + 546A29B3292A401500A80DE2 /* PopExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopExample.swift; sourceTree = ""; }; + 546A29B5292A406E00A80DE2 /* IrisExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrisExample.swift; sourceTree = ""; }; + 546A29B7292A40AF00A80DE2 /* MoveExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveExample.swift; sourceTree = ""; }; + 546A29B9292A40D800A80DE2 /* PoofExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoofExample.swift; sourceTree = ""; }; + 546A29BB292A414A00A80DE2 /* SnapshotExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotExample.swift; sourceTree = ""; }; + 546A29BD292A41CF00A80DE2 /* VanishExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VanishExample.swift; sourceTree = ""; }; + 546A29BF292A420E00A80DE2 /* SwooshExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwooshExample.swift; sourceTree = ""; }; + 546A29C1292A431800A80DE2 /* WipeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WipeExample.swift; sourceTree = ""; }; + 546A29C5292A4BC100A80DE2 /* JumpExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpExample.swift; sourceTree = ""; }; + 546A29C7292A4BFA00A80DE2 /* PingExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingExample.swift; sourceTree = ""; }; + 546A29C9292A4DBD00A80DE2 /* RiseExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiseExample.swift; sourceTree = ""; }; + 546A29CB292A559A00A80DE2 /* ShakeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShakeExample.swift; sourceTree = ""; }; + 546A29CD292A591F00A80DE2 /* ShineExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShineExample.swift; sourceTree = ""; }; + 546A29CF292A676200A80DE2 /* SkidExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkidExample.swift; sourceTree = ""; }; + 546A29D1292A68E100A80DE2 /* SpinExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinExample.swift; sourceTree = ""; }; + 546A29D3292A6B1200A80DE2 /* SprayExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SprayExample.swift; sourceTree = ""; }; + 549BF5F4293642BC00B2DCCF /* SocialFeedExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialFeedExample.swift; sourceTree = ""; }; + 54A9622F2934CC4400BBD5FE /* GithubButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubButton.swift; sourceTree = ""; }; + 54B7259629F9C70900C17B99 /* PushDownExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushDownExample.swift; sourceTree = ""; }; + 54D37CAD292F9C4A00788E8A /* Pow-Example-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Pow-Example-Info.plist"; sourceTree = SOURCE_ROOT; }; + 54F15E4F29D598150054690B /* RepeatExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatExample.swift; sourceTree = ""; }; + 54F15E5129D59A920054690B /* SmokeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeExample.swift; sourceTree = ""; }; + 54F15E5329D59D250054690B /* PulseExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PulseExample.swift; sourceTree = ""; }; + 54F15E5529D5A0D70054690B /* GlowExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlowExample.swift; sourceTree = ""; }; + B7A5DA8A2B0E85260035FC0A /* Pow */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pow; path = ..; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 546A2983292A31BB00A80DE2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B7A5DA8D2B0E853A0035FC0A /* Pow in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5413566C293E917E00EC0F1A /* Sounds */ = { + isa = PBXGroup; + children = ( + 541356FC293E93D500EC0F1A /* beep.m4a */, + 541356DD293E93D300EC0F1A /* biip.m4a */, + 541356FB293E93D500EC0F1A /* boop.m4a */, + 54135704293E93D600EC0F1A /* brush.m4a */, + 5413570F293E93D700EC0F1A /* chime.falling.m4a */, + 541356EF293E93D500EC0F1A /* chime.flat.m4a */, + 541356F7293E93D500EC0F1A /* chime.m4a */, + 541356E3293E93D400EC0F1A /* chime.rising.m4a */, + 54135710293E93D700EC0F1A /* detach.m4a */, + 541356E9293E93D400EC0F1A /* dial.m4a */, + 5413570C293E93D600EC0F1A /* drip.falling.m4a */, + 54135713293E93D700EC0F1A /* drip.flat.m4a */, + 54135711293E93D700EC0F1A /* drip.m4a */, + 5413570B293E93D600EC0F1A /* drip.rising.m4a */, + 54135700293E93D600EC0F1A /* glass.m4a */, + 541356EA293E93D400EC0F1A /* latch1.m4a */, + 54135702293E93D600EC0F1A /* latch2.m4a */, + 541356F3293E93D500EC0F1A /* latch3.m4a */, + 541356E8293E93D400EC0F1A /* latch4.m4a */, + 54135701293E93D600EC0F1A /* lock1.m4a */, + 541356E1293E93D400EC0F1A /* lock2.m4a */, + 541356F4293E93D500EC0F1A /* lock3.m4a */, + 541356F2293E93D500EC0F1A /* lock4.m4a */, + 541356EC293E93D400EC0F1A /* notfound.m4a */, + 541356E5293E93D400EC0F1A /* pick.falling.m4a */, + 541356ED293E93D400EC0F1A /* pick.flat.m4a */, + 54135708293E93D600EC0F1A /* pick.m4a */, + 54135706293E93D600EC0F1A /* pick.rising.m4a */, + 541356F8293E93D500EC0F1A /* ping.m4a */, + 541356F6293E93D500EC0F1A /* plop.m4a */, + 5413570E293E93D600EC0F1A /* pluck.m4a */, + 541356E2293E93D400EC0F1A /* pong.m4a */, + 54135712293E93D700EC0F1A /* pop1.m4a */, + 54135703293E93D600EC0F1A /* pop2.m4a */, + 541356E0293E93D400EC0F1A /* pop3.m4a */, + 541356FF293E93D600EC0F1A /* pop4.m4a */, + 541356F5293E93D500EC0F1A /* pop5.m4a */, + 541356E4293E93D400EC0F1A /* reel.falling.m4a */, + 541356DE293E93D300EC0F1A /* reel.flat.m4a */, + 541356DF293E93D400EC0F1A /* reel.m4a */, + 5413570D293E93D600EC0F1A /* reel.rising.m4a */, + 541356E7293E93D400EC0F1A /* shake.m4a */, + 54135707293E93D600EC0F1A /* snap.m4a */, + 541356E6293E93D400EC0F1A /* sparkle.falling.m4a */, + 541356FD293E93D500EC0F1A /* sparkle.flat.m4a */, + 5413570A293E93D600EC0F1A /* sparkle.m4a */, + 541356F9293E93D500EC0F1A /* sparkle.rising.m4a */, + 54135709293E93D600EC0F1A /* swipe.m4a */, + 541356FA293E93D500EC0F1A /* swish.m4a */, + 54135705293E93D600EC0F1A /* tick.m4a */, + 541356F1293E93D500EC0F1A /* tink.m4a */, + 541356F0293E93D500EC0F1A /* tock.m4a */, + 541356FE293E93D500EC0F1A /* whop.m4a */, + 541356EB293E93D400EC0F1A /* wip.m4a */, + 541356EE293E93D400EC0F1A /* zing.m4a */, + ); + path = Sounds; + sourceTree = ""; + }; + 546A297D292A31BB00A80DE2 = { + isa = PBXGroup; + children = ( + B7A5DA8A2B0E85260035FC0A /* Pow */, + 546A2988292A31BB00A80DE2 /* Pow Example */, + 546A2987292A31BB00A80DE2 /* Products */, + B7A5DA8B2B0E853A0035FC0A /* Frameworks */, + ); + sourceTree = ""; + }; + 546A2987292A31BB00A80DE2 /* Products */ = { + isa = PBXGroup; + children = ( + 546A2986292A31BB00A80DE2 /* Pow Example.app */, + ); + name = Products; + sourceTree = ""; + }; + 546A2988292A31BB00A80DE2 /* Pow Example */ = { + isa = PBXGroup; + children = ( + 54D37CAD292F9C4A00788E8A /* Pow-Example-Info.plist */, + 546A2989292A31BB00A80DE2 /* PowExampleApp.swift */, + 546A298B292A31BB00A80DE2 /* ExampleList.swift */, + 546A299D292A335600A80DE2 /* PlaceholderView.swift */, + 54A9622F2934CC4400BBD5FE /* GithubButton.swift */, + 546A299C292A332C00A80DE2 /* Examples */, + 546A298D292A31BC00A80DE2 /* Assets.xcassets */, + 5413566C293E917E00EC0F1A /* Sounds */, + 546A298F292A31BC00A80DE2 /* Preview Content */, + ); + path = "Pow Example"; + sourceTree = ""; + }; + 546A298F292A31BC00A80DE2 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 546A2990292A31BC00A80DE2 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 546A299C292A332C00A80DE2 /* Examples */ = { + isa = PBXGroup; + children = ( + 546A299F292A341700A80DE2 /* Example.swift */, + 546A29C3292A4B9000A80DE2 /* Transitions */, + 546A29C4292A4B9F00A80DE2 /* Change Effects */, + 54F15E4E29D598020054690B /* Conditional Effects */, + 549BF5F6293642BE00B2DCCF /* Screens */, + ); + path = Examples; + sourceTree = ""; + }; + 546A29C3292A4B9000A80DE2 /* Transitions */ = { + isa = PBXGroup; + children = ( + 546A299A292A332900A80DE2 /* AnvilExample.swift */, + 546A29A1292A349900A80DE2 /* BlindsExample.swift */, + 546A29A9292A397100A80DE2 /* BlurExample.swift */, + 546A29A5292A355400A80DE2 /* BoingExample.swift */, + 546A29A3292A34A600A80DE2 /* ClockExample.swift */, + 546A29AB292A3B9D00A80DE2 /* FilmExposureExample.swift */, + 546A29AF292A3DA800A80DE2 /* FlickerExample.swift */, + 546A29AD292A3D8900A80DE2 /* FlipExample.swift */, + 546A29B1292A3ED800A80DE2 /* GlareExample.swift */, + 546A29B5292A406E00A80DE2 /* IrisExample.swift */, + 546A29B7292A40AF00A80DE2 /* MoveExample.swift */, + 546A29B9292A40D800A80DE2 /* PoofExample.swift */, + 546A29B3292A401500A80DE2 /* PopExample.swift */, + 546A29CF292A676200A80DE2 /* SkidExample.swift */, + 546A29BB292A414A00A80DE2 /* SnapshotExample.swift */, + 546A29BF292A420E00A80DE2 /* SwooshExample.swift */, + 546A29BD292A41CF00A80DE2 /* VanishExample.swift */, + 546A29C1292A431800A80DE2 /* WipeExample.swift */, + ); + path = Transitions; + sourceTree = ""; + }; + 546A29C4292A4B9F00A80DE2 /* Change Effects */ = { + isa = PBXGroup; + children = ( + 546A29C5292A4BC100A80DE2 /* JumpExample.swift */, + 546A29C7292A4BFA00A80DE2 /* PingExample.swift */, + 546A29C9292A4DBD00A80DE2 /* RiseExample.swift */, + 546A29CB292A559A00A80DE2 /* ShakeExample.swift */, + 546A29CD292A591F00A80DE2 /* ShineExample.swift */, + 54F15E5529D5A0D70054690B /* GlowExample.swift */, + 541356DB293E91A000EC0F1A /* SoundEffectsExample.swift */, + 546A29D1292A68E100A80DE2 /* SpinExample.swift */, + 546A29D3292A6B1200A80DE2 /* SprayExample.swift */, + 54F15E5329D59D250054690B /* PulseExample.swift */, + ); + path = "Change Effects"; + sourceTree = ""; + }; + 549BF5F6293642BE00B2DCCF /* Screens */ = { + isa = PBXGroup; + children = ( + 236B13452940BAFD0022AF1F /* CheckoutExample.swift */, + 549BF5F4293642BC00B2DCCF /* SocialFeedExample.swift */, + ); + path = Screens; + sourceTree = ""; + }; + 54F15E4E29D598020054690B /* Conditional Effects */ = { + isa = PBXGroup; + children = ( + 54B7259629F9C70900C17B99 /* PushDownExample.swift */, + 54F15E4F29D598150054690B /* RepeatExample.swift */, + 54F15E5129D59A920054690B /* SmokeExample.swift */, + ); + path = "Conditional Effects"; + sourceTree = ""; + }; + B7A5DA8B2B0E853A0035FC0A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 546A2985292A31BB00A80DE2 /* Pow Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 546A2994292A31BC00A80DE2 /* Build configuration list for PBXNativeTarget "Pow Example" */; + buildPhases = ( + 546A2982292A31BB00A80DE2 /* Sources */, + 546A2983292A31BB00A80DE2 /* Frameworks */, + 546A2984292A31BB00A80DE2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Pow Example"; + packageProductDependencies = ( + B7A5DA8C2B0E853A0035FC0A /* Pow */, + ); + productName = "Pow Example"; + productReference = 546A2986292A31BB00A80DE2 /* Pow Example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 546A297E292A31BB00A80DE2 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1410; + LastUpgradeCheck = 1500; + TargetAttributes = { + 546A2985292A31BB00A80DE2 = { + CreatedOnToolsVersion = 14.1; + }; + }; + }; + buildConfigurationList = 546A2981292A31BB00A80DE2 /* Build configuration list for PBXProject "Pow Example" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 546A297D292A31BB00A80DE2; + packageReferences = ( + ); + productRefGroup = 546A2987292A31BB00A80DE2 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 546A2985292A31BB00A80DE2 /* Pow Example */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 546A2984292A31BB00A80DE2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5413571B293E941000EC0F1A /* sparkle.rising.m4a in Resources */, + 5413573D293E941000EC0F1A /* reel.rising.m4a in Resources */, + 54135732293E941000EC0F1A /* drip.flat.m4a in Resources */, + 54135721293E941000EC0F1A /* swipe.m4a in Resources */, + 5413571E293E941000EC0F1A /* pick.flat.m4a in Resources */, + 54135728293E941000EC0F1A /* snap.m4a in Resources */, + 54135725293E941000EC0F1A /* plop.m4a in Resources */, + 54135733293E941000EC0F1A /* latch2.m4a in Resources */, + 5413573A293E941000EC0F1A /* drip.m4a in Resources */, + 5413571D293E941000EC0F1A /* tock.m4a in Resources */, + 546A2991292A31BC00A80DE2 /* Preview Assets.xcassets in Resources */, + 5413571F293E941000EC0F1A /* chime.falling.m4a in Resources */, + 54135731293E941000EC0F1A /* boop.m4a in Resources */, + 54135742293E941000EC0F1A /* chime.flat.m4a in Resources */, + 5413572E293E941000EC0F1A /* shake.m4a in Resources */, + 5413571A293E941000EC0F1A /* reel.m4a in Resources */, + 54135730293E941000EC0F1A /* lock4.m4a in Resources */, + 54135734293E941000EC0F1A /* lock2.m4a in Resources */, + 5413573B293E941000EC0F1A /* beep.m4a in Resources */, + 54135720293E941000EC0F1A /* chime.m4a in Resources */, + 54135716293E941000EC0F1A /* latch1.m4a in Resources */, + 5413571C293E941000EC0F1A /* wip.m4a in Resources */, + 54135746293E941000EC0F1A /* lock3.m4a in Resources */, + 54135726293E941000EC0F1A /* detach.m4a in Resources */, + 54135737293E941000EC0F1A /* glass.m4a in Resources */, + 5413574A293E941000EC0F1A /* biip.m4a in Resources */, + 5413573F293E941000EC0F1A /* sparkle.flat.m4a in Resources */, + 54135749293E941000EC0F1A /* pop3.m4a in Resources */, + 54135736293E941000EC0F1A /* latch4.m4a in Resources */, + 54135745293E941000EC0F1A /* ping.m4a in Resources */, + 54135747293E941000EC0F1A /* pop1.m4a in Resources */, + 54135741293E941000EC0F1A /* pop2.m4a in Resources */, + 54135748293E941000EC0F1A /* sparkle.falling.m4a in Resources */, + 54135717293E941000EC0F1A /* pick.falling.m4a in Resources */, + 54135727293E941000EC0F1A /* swish.m4a in Resources */, + 54135724293E941000EC0F1A /* pick.rising.m4a in Resources */, + 5413572A293E941000EC0F1A /* chime.rising.m4a in Resources */, + 54135719293E941000EC0F1A /* pop4.m4a in Resources */, + 5413572D293E941000EC0F1A /* dial.m4a in Resources */, + 54135718293E941000EC0F1A /* brush.m4a in Resources */, + 54135744293E941000EC0F1A /* drip.rising.m4a in Resources */, + 5413573E293E941000EC0F1A /* sparkle.m4a in Resources */, + 54135739293E941000EC0F1A /* pong.m4a in Resources */, + 54135738293E941000EC0F1A /* zing.m4a in Resources */, + 5413572B293E941000EC0F1A /* notfound.m4a in Resources */, + 54135729293E941000EC0F1A /* drip.falling.m4a in Resources */, + 54135735293E941000EC0F1A /* pop5.m4a in Resources */, + 54135723293E941000EC0F1A /* lock1.m4a in Resources */, + 54135714293E941000EC0F1A /* pick.m4a in Resources */, + 5413573C293E941000EC0F1A /* latch3.m4a in Resources */, + 5413572C293E941000EC0F1A /* pluck.m4a in Resources */, + 546A298E292A31BC00A80DE2 /* Assets.xcassets in Resources */, + 54135715293E941000EC0F1A /* reel.falling.m4a in Resources */, + 54135740293E941000EC0F1A /* reel.flat.m4a in Resources */, + 5413572F293E941000EC0F1A /* tink.m4a in Resources */, + 54135743293E941000EC0F1A /* whop.m4a in Resources */, + 54135722293E941000EC0F1A /* tick.m4a in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 546A2982292A31BB00A80DE2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 546A298C292A31BB00A80DE2 /* ExampleList.swift in Sources */, + 54F15E5229D59A920054690B /* SmokeExample.swift in Sources */, + 546A29CE292A591F00A80DE2 /* ShineExample.swift in Sources */, + 546A29C0292A420F00A80DE2 /* SwooshExample.swift in Sources */, + 546A29BA292A40D800A80DE2 /* PoofExample.swift in Sources */, + 546A29B2292A3ED800A80DE2 /* GlareExample.swift in Sources */, + 546A29B6292A406E00A80DE2 /* IrisExample.swift in Sources */, + 546A29A4292A34A600A80DE2 /* ClockExample.swift in Sources */, + 546A29D2292A68E100A80DE2 /* SpinExample.swift in Sources */, + 546A29BE292A41CF00A80DE2 /* VanishExample.swift in Sources */, + 541356DC293E91A000EC0F1A /* SoundEffectsExample.swift in Sources */, + 546A29C8292A4BFA00A80DE2 /* PingExample.swift in Sources */, + 546A29C2292A431800A80DE2 /* WipeExample.swift in Sources */, + 546A298A292A31BB00A80DE2 /* PowExampleApp.swift in Sources */, + 549BF5F5293642BC00B2DCCF /* SocialFeedExample.swift in Sources */, + 546A299E292A335600A80DE2 /* PlaceholderView.swift in Sources */, + 54B7259729F9C70900C17B99 /* PushDownExample.swift in Sources */, + 546A29D0292A676200A80DE2 /* SkidExample.swift in Sources */, + 546A29C6292A4BC100A80DE2 /* JumpExample.swift in Sources */, + 546A29AA292A397100A80DE2 /* BlurExample.swift in Sources */, + 546A29A0292A341700A80DE2 /* Example.swift in Sources */, + 54F15E5629D5A0D70054690B /* GlowExample.swift in Sources */, + 236B13462940BAFD0022AF1F /* CheckoutExample.swift in Sources */, + 546A29BC292A414A00A80DE2 /* SnapshotExample.swift in Sources */, + 54A962302934CC4400BBD5FE /* GithubButton.swift in Sources */, + 546A29B4292A401500A80DE2 /* PopExample.swift in Sources */, + 546A29CC292A559A00A80DE2 /* ShakeExample.swift in Sources */, + 546A29AE292A3D8900A80DE2 /* FlipExample.swift in Sources */, + 546A29A6292A355400A80DE2 /* BoingExample.swift in Sources */, + 54F15E5429D59D250054690B /* PulseExample.swift in Sources */, + 546A299B292A332900A80DE2 /* AnvilExample.swift in Sources */, + 546A29A2292A349900A80DE2 /* BlindsExample.swift in Sources */, + 546A29AC292A3B9D00A80DE2 /* FilmExposureExample.swift in Sources */, + 546A29B0292A3DA800A80DE2 /* FlickerExample.swift in Sources */, + 54F15E5029D598150054690B /* RepeatExample.swift in Sources */, + 546A29D4292A6B1200A80DE2 /* SprayExample.swift in Sources */, + 546A29CA292A4DBD00A80DE2 /* RiseExample.swift in Sources */, + 546A29B8292A40B000A80DE2 /* MoveExample.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 546A2992292A31BC00A80DE2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 546A2993292A31BC00A80DE2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 546A2995292A31BC00A80DE2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 8; + DEVELOPMENT_ASSET_PATHS = "\"Pow Example/Preview Content\""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Pow-Example-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Pow; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.movingparts.Pow-Example"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 546A2996292A31BC00A80DE2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 8; + DEVELOPMENT_ASSET_PATHS = "\"Pow Example/Preview Content\""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Pow-Example-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Pow; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "io.movingparts.Pow-Example"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 546A2981292A31BB00A80DE2 /* Build configuration list for PBXProject "Pow Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 546A2992292A31BC00A80DE2 /* Debug */, + 546A2993292A31BC00A80DE2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 546A2994292A31BC00A80DE2 /* Build configuration list for PBXNativeTarget "Pow Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 546A2995292A31BC00A80DE2 /* Debug */, + 546A2996292A31BC00A80DE2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + B7A5DA8C2B0E853A0035FC0A /* Pow */ = { + isa = XCSwiftPackageProductDependency; + productName = Pow; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 546A297E292A31BB00A80DE2 /* Project object */; +} diff --git a/Example/Pow Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/Pow Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/Pow Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/Pow Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Pow Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Example/Pow Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/Pow Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/Pow Example/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/Pow Example/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Pow Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Pow Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a657e33 --- /dev/null +++ b/Example/Pow Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Pow Example/Assets.xcassets/AppIcon.appiconset/icon.png b/Example/Pow Example/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 0000000..dab6f29 Binary files /dev/null and b/Example/Pow Example/Assets.xcassets/AppIcon.appiconset/icon.png differ diff --git a/Example/Pow Example/Assets.xcassets/Contents.json b/Example/Pow Example/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Pow Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Pow Example/Assets.xcassets/disco.imageset/Contents.json b/Example/Pow Example/Assets.xcassets/disco.imageset/Contents.json new file mode 100644 index 0000000..c24276f --- /dev/null +++ b/Example/Pow Example/Assets.xcassets/disco.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "disco.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Pow Example/Assets.xcassets/disco.imageset/disco.jpg b/Example/Pow Example/Assets.xcassets/disco.imageset/disco.jpg new file mode 100644 index 0000000..8301b5f Binary files /dev/null and b/Example/Pow Example/Assets.xcassets/disco.imageset/disco.jpg differ diff --git a/Example/Pow Example/Assets.xcassets/mvp.imageset/Contents.json b/Example/Pow Example/Assets.xcassets/mvp.imageset/Contents.json new file mode 100644 index 0000000..1b8b7b0 --- /dev/null +++ b/Example/Pow Example/Assets.xcassets/mvp.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mvp.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Pow Example/Assets.xcassets/mvp.imageset/mvp.png b/Example/Pow Example/Assets.xcassets/mvp.imageset/mvp.png new file mode 100644 index 0000000..4d5541e Binary files /dev/null and b/Example/Pow Example/Assets.xcassets/mvp.imageset/mvp.png differ diff --git a/Example/Pow Example/ExampleList.swift b/Example/Pow Example/ExampleList.swift new file mode 100644 index 0000000..f206829 --- /dev/null +++ b/Example/Pow Example/ExampleList.swift @@ -0,0 +1,158 @@ +import Pow +import MessageUI +import SwiftUI + +struct ExampleList: View { + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 12) { + Text("This is the official example app for Pow, the Surprise and Delight framework for SwiftUI.") + + Text("Tap the individual examples to see the effects and transitions in action.") + + Text("**Note:** While this app requires iOS 16, Pow itself supports iOS 15 and above.") + } + .font(.subheadline.leading(.loose)) + .foregroundColor(.primary) + + Link(destination: URL(string: "https://movingparts.io/pow")!) { + ViewThatFits { + Label("Pow Website", systemImage: "safari") + Label("Pow Website", systemImage: "safari") + Label("Pow Website", systemImage: "safari") + Label("Pow Website", systemImage: "safari") + } + } + + Link(destination: URL(string: "https://github.com/movingparts-io/Pow-Examples")!) { + ViewThatFits { + Label("GitHub Repository for this App", systemImage: "terminal") + Label("GitHub Repo for this App", systemImage: "terminal") + Label("Repo for this App", systemImage: "terminal") + } + } + + if MFMailComposeViewController.canSendMail() { + Link(destination: URL(string: "mailto:hello@movingparts.io")!) { + Label("Support", systemImage: "envelope") + } + } + } + + Section { + SocialFeedExample.navigationLink + CheckoutExample.navigationLink + } header: { + Label("Screens", systemImage: "iphone") + } footer: { + Text("Pre-composed screens that show how to use Pow in context. Use them as inspiration for your app.") + } + + Section { + PushDownExample.navigationLink + RepeatExample.navigationLink + SmokeExample.navigationLink + } header: { + Label("Conditional Effects", systemImage: "checklist") + } footer: { + Text("Conditional Effects are triggered continously, as long as a condition is met.") + } + + Section { + GlowExample.navigationLink + PulseExample.navigationLink + JumpExample.navigationLink + PingExample.navigationLink + RiseExample.navigationLink + ShakeExample.navigationLink + ShineExample.navigationLink + SoundEffectExample.navigationLink + SpinExample.navigationLink + SprayExample.navigationLink + } header: { + Label("Change Effects", systemImage: "sparkles") + } footer: { + Text("Change Effects can be triggered whenever a value changes.") + } + + Section { + Group { + AnvilExample.navigationLink + BlindsExample.navigationLink + BlurExample.navigationLink + BoingExample.navigationLink + ClockExample.navigationLink + FilmExposureExample.navigationLink + FlickerExample.navigationLink + FlipExample.navigationLink + GlareExample.navigationLink + } + Group { + IrisExample.navigationLink + MoveExample.navigationLink + PoofExample.navigationLink + PopExample.navigationLink + SkidExample.navigationLink + SnapshotExample.navigationLink + SwooshExample.navigationLink + VanishExample.navigationLink + WipeExample.navigationLink + } + } header: { + Label("Transitions", systemImage: "arrow.forward.square") + } footer: { + Text("Transitions use the existing SwiftUI `.transition(_:)` API.") + } + } + .navigationTitle("Pow Examples") + } +} + +struct PresentInfoAction { + var action: (any Example.Type) -> () + + init(action: @escaping (any Example.Type) -> Void) { + self.action = action + } + + func callAsFunction(_ type: T.Type) { + action(type) + } +} + +extension EnvironmentValues { + struct PresentInfoActionKey: EnvironmentKey { + static var defaultValue: PresentInfoAction? = nil + } + + var presentInfoAction: PresentInfoAction? { + get { self[PresentInfoActionKey.self] } + set { self[PresentInfoActionKey.self] = newValue } + } +} + +struct InfoButton: View { + var type: T.Type + + @Environment(\.presentInfoAction) + var presentInfoAction + + var body: some View { + if let presentInfoAction { + Button { + presentInfoAction(type) + } label: { + Label("About", systemImage: "info.circle") + } + } + } +} + +struct ExampleList_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ExampleList() + } + } +} diff --git a/Example/Pow Example/Examples/Change Effects/GlowExample.swift b/Example/Pow Example/Examples/Change Effects/GlowExample.swift new file mode 100644 index 0000000..3519d73 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/GlowExample.swift @@ -0,0 +1,71 @@ +import Pow +import SwiftUI + +struct GlowExample: View, Example { + @State + var changes: Int = 0 + + var body: some View { + VStack { +// GroupBox { +// LabeledContent("Drawing Mode") { +// Picker("Drawing Mode", selection: $drawingMode) { +// Text("Fill").tag(AnyChangeEffect.PulseDrawingMode.fill) +// Text("Stroke").tag(AnyChangeEffect.PulseDrawingMode.stroke) +// } +// } +// } +// .padding(.horizontal) + + Spacer() + + ZStack { + PlaceholderView() + .overlay(alignment: .badgeAlignment) { + let shape = Capsule() + + Text(changes.formatted()) + .font(.body.bold().monospacedDigit()) + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background { + shape.fill(.pink) + .changeEffect(.glow(color: .pink, radius: 20), value: changes) + } + .alignmentGuide(HorizontalAlignment.badgeAlignment) { d in + d[HorizontalAlignment.center] + } + .alignmentGuide(VerticalAlignment.badgeAlignment) { d in + d[VerticalAlignment.center] + } + .allowsHitTesting(false) + } + } + + Spacer() + } + .defaultBackground() + .onTapGesture { + changes += 1 + } + } + + static var description: some View { + Text(""" + Makes the view glow whenever a value changes + + - Parameters: + - `color`: The color to use. + - `radius`: The radius of the glow. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "dot.radiowaves.left.and.right") + } + + static var newIn0_3_0: Bool { true } +} diff --git a/Example/Pow Example/Examples/Change Effects/JumpExample.swift b/Example/Pow Example/Examples/Change Effects/JumpExample.swift new file mode 100644 index 0000000..da9aa40 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/JumpExample.swift @@ -0,0 +1,32 @@ +import Pow +import SwiftUI + +struct JumpExample: View, Example { + @State + var changes: Int = 0 + + var body: some View { + ZStack { + PlaceholderView() + .changeEffect(.jump(height: 40), value: changes) + } + .defaultBackground() + .onTapGesture { + changes += 1 + } + } + + static var description: some View { + Text(""" + Makes the view jump the given height and then bounces a few times before settling. + + - `height`: The height of the jump. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "figure.jumprope") + } +} diff --git a/Example/Pow Example/Examples/Change Effects/PingExample.swift b/Example/Pow Example/Examples/Change Effects/PingExample.swift new file mode 100644 index 0000000..4728b80 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/PingExample.swift @@ -0,0 +1,79 @@ +import Pow +import SwiftUI + +struct PingExample: View, Example { + @State + var changes: Int = 0 + + var body: some View { + ZStack { + PlaceholderView() + .overlay(alignment: .badgeAlignment) { + let shape = Capsule() + + Text(changes.formatted()) + .font(.body.bold().monospacedDigit()) + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background { + shape.fill(.pink) + .changeEffect(.pulse(shape: shape, style: .pink, count: 3), value: changes) + } + .alignmentGuide(HorizontalAlignment.badgeAlignment) { d in + d[HorizontalAlignment.center] + } + .alignmentGuide(VerticalAlignment.badgeAlignment) { d in + d[VerticalAlignment.center] + } + } + } + .defaultBackground() + .onTapGesture { + changes += 1 + } + } + + static var description: some View { + Text(""" + Adds one or more shapes that slowly grow and fade-out behind the view. + + The shape will be colored by the current tint style. + + -Parameters: + - `shape`: The shape to use for the effect. + - `style`: The style to use for the effect. + - `count`: The number of shapes to emit. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "dot.radiowaves.left.and.right") + } +} + +extension VerticalAlignment { + struct BadgeAlignmentID: AlignmentID { + static func defaultValue(in d: ViewDimensions) -> CGFloat { + d[.top] + } + } + + static let badgeAlignment = VerticalAlignment(BadgeAlignmentID.self) +} + +extension HorizontalAlignment { + struct BadgeAlignmentID: AlignmentID { + static func defaultValue(in d: ViewDimensions) -> CGFloat { + d[.trailing] + } + } + + static let badgeAlignment = HorizontalAlignment(BadgeAlignmentID.self) +} + +extension Alignment { + static let badgeAlignment = Alignment(horizontal: .badgeAlignment, vertical: .badgeAlignment) +} diff --git a/Example/Pow Example/Examples/Change Effects/PulseExample.swift b/Example/Pow Example/Examples/Change Effects/PulseExample.swift new file mode 100644 index 0000000..6f6d2f4 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/PulseExample.swift @@ -0,0 +1,79 @@ +import Pow +import SwiftUI + +struct PulseExample: View, Example { + @State + var changes: Int = 0 + + @State + var drawingMode: AnyChangeEffect.PulseDrawingMode = .fill + + var body: some View { + VStack { + GroupBox { + LabeledContent("Drawing Mode") { + Picker("Drawing Mode", selection: $drawingMode) { + Text("Fill").tag(AnyChangeEffect.PulseDrawingMode.fill) + Text("Stroke").tag(AnyChangeEffect.PulseDrawingMode.stroke) + } + } + } + .padding(.horizontal) + + Spacer() + + ZStack { + PlaceholderView() + .overlay(alignment: .badgeAlignment) { + let shape = Capsule() + + Text(changes.formatted()) + .font(.body.bold().monospacedDigit()) + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background { + shape.fill(.pink) + .changeEffect(.pulse(shape: shape, style: .pink, drawingMode: drawingMode, count: 1), value: changes) + } + .alignmentGuide(HorizontalAlignment.badgeAlignment) { d in + d[HorizontalAlignment.center] + } + .alignmentGuide(VerticalAlignment.badgeAlignment) { d in + d[VerticalAlignment.center] + } + .allowsHitTesting(false) + } + } + + Spacer() + } + .defaultBackground() + .onTapGesture { + changes += 1 + } + } + + static var description: some View { + Text(""" + Adds one or more shapes that are emitted from the view. + + By default, the shape will be colored in the current tint style. + + - Parameters: + - `shape`: The shape to use for the effect. + - `style`: The style to use for the effect. + - `drawingMode` Changes between filled or stroked shapes. Default is `.fill`. + - `count`: The number of shapes to emit. + - `layer` The particle layer to use. Prevents the shape from being clipped by the parent view. (Optional) + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "dot.radiowaves.left.and.right") + } + + static var newIn0_3_0: Bool { true } +} diff --git a/Example/Pow Example/Examples/Change Effects/RiseExample.swift b/Example/Pow Example/Examples/Change Effects/RiseExample.swift new file mode 100644 index 0000000..180718c --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/RiseExample.swift @@ -0,0 +1,61 @@ +import Pow +import SwiftUI + +struct RiseExample: View, Example { + @State + var changes: Int = 0 + + var body: some View { + let colors = [Color.red, .orange, .yellow, .green, .blue, .indigo, .purple] + + ZStack { + Label { + Text(changes.formatted()) + .contentTransition(.identity) + .monospacedDigit() + .changeEffect(.rise { + // Rise will cycle through provided views + ForEach(colors, id: \.self) { color in + Text("+1") + .foregroundStyle(color.gradient) + .shadow(color: color.opacity(0.4), radius: 0.5, y: 0.5) + } + .font(.system(.body, design: .rounded, weight: .bold)) + }, value: changes) + } icon: { + Image(systemName: "star.fill") + .foregroundStyle( + LinearGradient(colors: colors, startPoint: UnitPoint(x: 0.2, y: 0.2), endPoint: UnitPoint(x: 0.8, y: 0.8)) + ) + } + .padding(.vertical, 8) + .padding(.leading, 16) + .padding(.trailing, 20) + .background(.thinMaterial, in: Capsule()) + .foregroundColor(.primary) + .font(.system(.title, design: .rounded, weight: .bold)) + } + .defaultBackground() + .onTapGesture { + withAnimation { + changes += 1 + } + } + } + + static var description: some View { + Text(""" + An effect that emits the provided particles from the origin point and slowly float up while moving side to side. + + - Parameters: + - `origin`: The origin of the particle. + - `particles`: The particles to emit. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.up.and.down.and.sparkles") + } +} diff --git a/Example/Pow Example/Examples/Change Effects/ShakeExample.swift b/Example/Pow Example/Examples/Change Effects/ShakeExample.swift new file mode 100644 index 0000000..997d94c --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/ShakeExample.swift @@ -0,0 +1,53 @@ +import Pow +import SwiftUI + +struct ShakeExample: View, Example { + @State var password = "" + + @State var loginAttempts = 0 + + @State var isProcessing = false + + var body: some View { + ZStack { + GroupBox("Sign In") { + VStack(alignment: .leading, spacing: 12) { + SecureField("Password", text: $password) + .changeEffect(.shake(rate: .fast), value: loginAttempts) + .onSubmit { + Task { + isProcessing = true + defer { isProcessing = false } + + try? await Task.sleep(for: .seconds(1)) + + loginAttempts += 1 + } + } + .disabled(isProcessing) + .textFieldStyle(.roundedBorder) + .changeEffect(.shake(rate: .fast), value: loginAttempts) + + Text("Submit the form to see the effect.").font(.caption).foregroundColor(.secondary) + } + } + .frame(maxWidth: 320) + .padding(24) + } + .defaultBackground() + } + + static var description: some View { + Text(""" + An effect that shakes the view when a change happens. + + - `rate`: The rate of the shake. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.left.arrow.right") + } +} diff --git a/Example/Pow Example/Examples/Change Effects/ShineExample.swift b/Example/Pow Example/Examples/Change Effects/ShineExample.swift new file mode 100644 index 0000000..3a59c37 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/ShineExample.swift @@ -0,0 +1,54 @@ +import Pow +import SwiftUI + +struct ShineExample: View, Example { + @State var name = "" + + + var body: some View { + ZStack { + GroupBox("Sign In") { + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .padding(.bottom, 24) + + Button { + + } label: { + Spacer() + Text("Submit") + Spacer() + } + .disabled(name.isEmpty) + .changeEffect(.shine.delay(1), value: name.isEmpty, isEnabled: !name.isEmpty) + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: 320) + .padding(24) + } + .defaultBackground() + .onTapGesture { + if name.isEmpty { + name = "Jay Appleseed" + } + } + } + + static var description: some View { + Text(""" + Highlights the view with a shine moving over the view. + + The angle is relative to the current `layoutDirection`, such that 0° represents sweeping towards the trailing edge and 90° represents sweeping towards the top edge. + + - Parameters: + - `angle`: The angle of the animation. + - `duration`: The duration of the animation. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "sparkles") + } +} diff --git a/Example/Pow Example/Examples/Change Effects/SoundEffectsExample.swift b/Example/Pow Example/Examples/Change Effects/SoundEffectsExample.swift new file mode 100644 index 0000000..8e323b6 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/SoundEffectsExample.swift @@ -0,0 +1,182 @@ +import Pow +import SwiftUI + +struct SoundEffectExample: View, Example { + var body: some View { + // All `SoundEffects` used here can be found in the + // `Pow Example/Sounds/` folder and are free to use with any licensed + // copy of Pow. + ScrollView { + VStack { + GroupBox("Alerts") { + HStack { + SoundEffectPad("Not Found", SoundEffect("notfound")) + SoundEffectPad("Pluck", SoundEffect("pluck")) + SoundEffectPad("Pong", SoundEffect("pong")) + SoundEffectPad("Ping", SoundEffect("ping")) + } + } + + GroupBox("Blips") { + HStack { + SoundEffectPad("Boop", SoundEffect("boop")) + SoundEffectPad("Beep", SoundEffect("beep")) + SoundEffectPad("Biip", SoundEffect("biip")) + SoundEffectPad("Biip", SoundEffect("biip")).hidden() + } + } + + GroupBox("Clicks & Plops") { + HStack { + SoundEffectPad("Dial", SoundEffect("dial")) + SoundEffectPad("Tock", SoundEffect("tock")) + SoundEffectPad("Plop", SoundEffect("plop")) + SoundEffectPad("Pop", SoundEffect("pop1", "pop2", "pop3", "pop4", "pop5")) + } + } + + GroupBox("Drips") { + HStack { + SoundEffectPad("Drip", SoundEffect("drip")) + SoundEffectPad("Drip\nFlat", SoundEffect("drip.flat")) + SoundEffectPad("Drip\nRising", SoundEffect("drip.rising")) + SoundEffectPad("Drip\nFalling", SoundEffect("drip.falling")) + } + } + + GroupBox("Glas") { + HStack { + SoundEffectPad("Tink", SoundEffect("tink")) + SoundEffectPad("Zing", SoundEffect("zing")) + SoundEffectPad("Glass", SoundEffect("glass")) + SoundEffectPad("Tick", SoundEffect("tick")) + } + } + + GroupBox("Metal") { + HStack { + SoundEffectPad("Latch", SoundEffect("latch1", "latch2", "latch3", "latch4")) + SoundEffectPad("Lock", SoundEffect("lock1", "lock2", "lock3", "lock4")) + SoundEffectPad("Snap", SoundEffect("snap")) + SoundEffectPad("Snap", SoundEffect("snap")).hidden() + } + } + + GroupBox("Notifications") { + HStack { + SoundEffectPad("Chime", SoundEffect("chime")) + SoundEffectPad("Chime\nFlat", SoundEffect("chime.flat")) + SoundEffectPad("Chime\nRising", SoundEffect("chime.rising")) + SoundEffectPad("Chime\nFalling", SoundEffect("chime.falling")) + } + HStack { + SoundEffectPad("Pick", SoundEffect("pick")) + SoundEffectPad("Pick\nFlat", SoundEffect("pick.flat")) + SoundEffectPad("Pick\nRising", SoundEffect("pick.rising")) + SoundEffectPad("Pick\nFalling", SoundEffect("pick.falling")) + } + } + + GroupBox("Results") { + HStack { + SoundEffectPad("Sparkle", SoundEffect("sparkle")) + SoundEffectPad("Sparkle\nFlat", SoundEffect("sparkle.flat")) + SoundEffectPad("Sparkle\nRising", SoundEffect("sparkle.rising")) + SoundEffectPad("Sparkle\nFalling", SoundEffect("sparkle.falling")) + } + } + + GroupBox("Tension/Release") { + HStack { + SoundEffectPad("Reel", SoundEffect("reel")) + SoundEffectPad("Reel\nFlat", SoundEffect("reel.flat")) + SoundEffectPad("Reel\nRising", SoundEffect("reel.rising")) + SoundEffectPad("Reel\nFalling", SoundEffect("reel.falling")) + } + } + + GroupBox("Undo/Redo") { + HStack { + SoundEffectPad("Brush", SoundEffect("brush")) + SoundEffectPad("Shake", SoundEffect("shake")) + SoundEffectPad("Swipe", SoundEffect("swipe")) + SoundEffectPad("Swish", SoundEffect("swish")) + } + + HStack { + SoundEffectPad("Wip", SoundEffect("wip")) + SoundEffectPad("Whooop", SoundEffect("whop")) + SoundEffectPad("Detach", SoundEffect("detach")) + SoundEffectPad("Detach", SoundEffect("detach")).hidden() + } + } + } + .padding() + } + .buttonStyle(SoundEffectButtonStyle()) + .buttonStyle(.bordered) + } + + static var description: some View { + Text(""" + Triggers the playback of a sound. + + - Parameters: + - `effect`: The `SoundEffect` to play back. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "speaker.wave.2") + } + + static let newIn0_2_0: Bool = true +} + +private struct SoundEffectPad: View { + var name: String + + var effect: SoundEffect + + init(_ name: String, _ effect: SoundEffect) { + self.name = name + self.effect = effect + } + + @State + private var triggers = 0 + + var body: some View { + Button(name) { + triggers += 1 + } + .changeEffect(.feedback(effect), value: triggers) + } +} + +private struct SoundEffectButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .font(.caption2) + .padding(4) + .foregroundStyle(.secondary) + .background(.tertiary, in: RoundedRectangle(cornerRadius: 3, style: .continuous)) + .overlay { + if configuration.isPressed { + Circle() + .fill(RadialGradient(colors: [.white, .white.opacity(0.0)], center: .center, startRadius: 0, endRadius: 30)) + .opacity(0.5) + } + } + .frame(height: 64) + } +} + +struct SoundEffectExample_Previews: PreviewProvider { + static var previews: some View { + SoundEffectExample() + } +} diff --git a/Example/Pow Example/Examples/Change Effects/SpinExample.swift b/Example/Pow Example/Examples/Change Effects/SpinExample.swift new file mode 100644 index 0000000..48ef2a1 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/SpinExample.swift @@ -0,0 +1,52 @@ +import Pow +import SwiftUI + +struct SpinExample: View, Example { + @State + var changes: Int = 0 + + var body: some View { + ZStack { + Label { + Text(changes.formatted()) + .contentTransition(.identity) + .monospacedDigit() + } icon: { + Image(systemName: "hand.thumbsup.fill") + .foregroundStyle(.blue.gradient) + .changeEffect(.spin(axis: (0, 1, -0.05), anchor: UnitPoint(x: 0.5, y: 0.5), perspective: 0.6, rate: .fast), value: changes) + } + .padding(.vertical, 8) + .padding(.leading, 16) + .padding(.trailing, 24) + .background(.thinMaterial, in: Capsule(style: .continuous)) + .foregroundColor(.primary) + .font(.system(.title, design: .rounded, weight: .bold)) + } + .defaultBackground() + .onTapGesture { + withAnimation { + changes += 1 + } + } + } + + static var description: some View { + Text(""" + Spins the view around the given axis when a change happens. + + - Parameters: + - `axis`: The x, y and z elements that specify the axis of rotation. + - `anchor`: The location with a default of center that defines a point in 3D space about which the rotation is anchored. + - `anchorZ`: The location with a default of 0 that defines a point in 3D space about which the rotation is anchored. + - `perspective`: The relative vanishing point with a default of 1 / 6 for this rotation. + - `rate`: How fast the the view spins. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.clockwise") + } +} diff --git a/Example/Pow Example/Examples/Change Effects/SprayExample.swift b/Example/Pow Example/Examples/Change Effects/SprayExample.swift new file mode 100644 index 0000000..dfa8e62 --- /dev/null +++ b/Example/Pow Example/Examples/Change Effects/SprayExample.swift @@ -0,0 +1,71 @@ +import Pow +import SwiftUI + +struct SprayExample: View, Example { + @State + var isFavorited: Bool = false + + var body: some View { + ZStack { + Label { + let favoriteCount = isFavorited ? 143 : 142 + + Text(favoriteCount.formatted()) + .contentTransition(.numericText()) + .monospacedDigit() + } icon: { + ZStack { + Image(systemName: "heart") + .foregroundColor(.gray) + .fontWeight(.light) + .opacity(isFavorited ? 0 : 1) + + Image(systemName: "heart.fill") + .foregroundStyle(.pink.gradient) + .scaleEffect(isFavorited ? 1 : 0.1, anchor: .center) + .opacity(isFavorited ? 1 : 0) + } + .changeEffect(.spray { + Group { + Image(systemName: "heart.fill") + Image(systemName: "sparkles") + } + .font(.title) + .foregroundStyle(.pink.gradient) + }, value: isFavorited, isEnabled: isFavorited) + } + .padding(.vertical, 8) + .padding(.leading, 16) + .padding(.trailing, 24) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.foreground) + .opacity(0.3) + } + .foregroundStyle(isFavorited ? .pink : .secondary) + .font(.system(.title, design: .rounded, weight: .semibold)) + } + .defaultBackground() + .onTapGesture { + withAnimation(.movingParts.overshoot(duration: 0.4)) { + isFavorited.toggle() + } + } + } + + static var description: some View { + Text(""" + An effect that emits multiple particles in different shades and sizes moving up from the origin point. + + - Parameters: + - `origin`: The origin of the particles. + - `particles`: The particles to emit. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "party.popper") + } +} diff --git a/Example/Pow Example/Examples/Conditional Effects/PushDownExample.swift b/Example/Pow Example/Examples/Conditional Effects/PushDownExample.swift new file mode 100644 index 0000000..4c748de --- /dev/null +++ b/Example/Pow Example/Examples/Conditional Effects/PushDownExample.swift @@ -0,0 +1,45 @@ +import Pow +import SwiftUI + +struct PushDownExample: View, Example { + @State + var isPressed: Bool = false + + var body: some View { + VStack { + Spacer() + + Text("Push me") + .font(.system(.title, design: .rounded, weight: .semibold)) + .blendMode(.destinationOut) + .padding() + .frame(maxWidth: .infinity) + .background(Color.accentColor.gradient, in: Capsule(style: .continuous)) + ._onButtonGesture { + isPressed = $0 + } perform: { + + } + .conditionalEffect(.pushDown, condition: isPressed) + .compositingGroup() + .padding() + + Spacer() + } + .defaultBackground() + } + + static var description: some View { + Text(""" + Scales the view down as if pushed wile a condition is met. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.down.to.line.compact") + } + + static var newIn0_3_0: Bool { true } +} diff --git a/Example/Pow Example/Examples/Conditional Effects/RepeatExample.swift b/Example/Pow Example/Examples/Conditional Effects/RepeatExample.swift new file mode 100644 index 0000000..1e10cb4 --- /dev/null +++ b/Example/Pow Example/Examples/Conditional Effects/RepeatExample.swift @@ -0,0 +1,58 @@ +import Pow +import SwiftUI + +struct RepeatExample: View, Example { + @State + var isEnabled: Bool = false + + var body: some View { + VStack { + GroupBox { + Toggle("Enable Effect", isOn: $isEnabled.animation()) + } + .padding(.horizontal) + + Spacer() + + Button { + + } label: { + Label("Accept", systemImage: "phone.fill") + } + .tint(.green) + .disabled(!isEnabled) + .conditionalEffect(.repeat(.wiggle(rate: .fast), every: .seconds(2)), condition: isEnabled) + + Button { + + } label: { + Label("Update", systemImage: "sparkles") + } + .disabled(!isEnabled) + .conditionalEffect(.repeat(.shine, every: .seconds(2)), condition: isEnabled) + + Spacer() + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + .defaultBackground() + .autotoggle($isEnabled) + } + + static var description: some View { + Text(""" + Repeats an `AnyChangeEffect` at regular intervals. + + - `effect`: The effect to repeat. + - `interval` The candence at which the effect is repeated. + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.counterclockwise") + } + + static var newIn0_3_0: Bool { true } +} diff --git a/Example/Pow Example/Examples/Conditional Effects/SmokeExample.swift b/Example/Pow Example/Examples/Conditional Effects/SmokeExample.swift new file mode 100644 index 0000000..41a832e --- /dev/null +++ b/Example/Pow Example/Examples/Conditional Effects/SmokeExample.swift @@ -0,0 +1,66 @@ +import Pow +import SwiftUI + +struct SmokeExample: View, Example { + @State + var isEnabled: Bool = false + + var body: some View { + VStack { + GroupBox { + Toggle("Enable Effect", isOn: $isEnabled.animation()) + } + .padding(.horizontal) + + Spacer() + + ZStack { + Circle() + .fill(.orange.gradient) + .brightness(-0.1) + + Rectangle() + .fill(.white.gradient) + .mask { + ZStack { + Circle() + .strokeBorder(.white.opacity(0.8).gradient, lineWidth: 4) + .padding(6) + + Image(systemName: "opticaldiscdrive.fill") + .imageScale(.large) + .font(.system(size: 40, weight: .black)) + .offset(y: -2) + } + } + .blendMode(.lighten) + } + .compositingGroup() + .drawingGroup() + .frame(width: 120, height: 120) + .grayscale(isEnabled ? 0 : 1) + .conditionalEffect(.smoke(layer: .named("root")), condition: isEnabled) + + Spacer() + + } + .defaultBackground() + .autotoggle($isEnabled) + } + + static var description: some View { + Text(""" + Emmits smoke from behind the view. + + - `layer` The particle layer to use. Prevents the smoke from being clipped by the parent view. (Optional) + """) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "flame") + } + + static var newIn0_3_0: Bool { true } +} diff --git a/Example/Pow Example/Examples/Example.swift b/Example/Pow Example/Examples/Example.swift new file mode 100644 index 0000000..5df7899 --- /dev/null +++ b/Example/Pow Example/Examples/Example.swift @@ -0,0 +1,217 @@ +import SwiftUI + +protocol Example: View { + associatedtype Description: View + + init() + + static var title: String { get } + + @ViewBuilder + static var description: Description { get } + + static var icon: Image? { get } + + static var localPath: LocalPath { get } + + static var newIn0_2_0: Bool { get } + + static var newIn0_3_0: Bool { get } +} + +extension Example { + static var title: String { + String(describing: type(of: self)) + .replacingOccurrences(of: "Example.Type", with: "") + .reduce(into: "") { string, character in + if string.last?.isUppercase == false && character.isUppercase { + string.append(" ") + } + + string.append(character) + } + } + + @ViewBuilder + static var navigationLink: NavigationLink { + NavigationLink { + ZStack { + Self() + .background() + .toolbar { + GithubButton(Self.localPath) + + if type(of: Self.description) != EmptyView.self { + InfoButton(type: Self.self) + } + } + .navigationTitle(title) + } + } label: { + let colors = [Color.red, .orange, .yellow, .green, .blue, .indigo, .purple, .mint] + + var rng = MinimalPCG(string: title) + + Label { + Text(title) + .layoutPriority(1) + + if newIn0_2_0 { + Spacer() + + NewBadge("0.2.0") + } + + if newIn0_3_0 { + Spacer() + + NewBadge("0.3.0") + } + } icon: { + IconView { + icon ?? Image(systemName: "wand.and.stars.inverse") + } + .foregroundStyle(colors[Int(rng.next()) % colors.count].gradient) + } + } + } + + static var icon: Image? { nil } + + static var newIn0_2_0: Bool { false } + + static var newIn0_3_0: Bool { false } + + static var description: some View { + EmptyView() + } + + static var erasedDescription: AnyView { + AnyView(description) + } +} + +extension View { + func defaultBackground() -> some View { + self + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Rectangle().fill(.background).ignoresSafeArea()) + .contentShape(Rectangle()) + } + + func autotoggle(_ binding: Binding, with animation: Animation = .default) -> some View { + self + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation(animation) { + binding.wrappedValue = true + } + } + } + } +} + +struct NewBadge: View { + var version: String + + init(_ version: String) { + self.version = version + } + + var body: some View { + ViewThatFits { + Text("New in \(version)").fixedSize() + Text("\(version)").fixedSize() + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .font(.caption2.monospacedDigit()) + .textCase(.uppercase) + .bold() + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.thinMaterial, in: Capsule()) + .overlay { + Capsule() + .stroke(.quaternary) + } + } +} + +struct IconView: View { + var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + @Environment(\.colorScheme) + var colorScheme + + var body: some View { + ZStack { + Rectangle() + .fill(.primary) + .aspectRatio(1, contentMode: .fill) + .frame(width: 28, height: 28) + .brightness(colorScheme == .dark ? -0.2 : -0.03) + + content + .foregroundStyle(.white) + } + .font(.system(size: 18)) + .imageScale(.small) + .symbolRenderingMode(.monochrome) + .symbolVariant(.fill) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(.white.opacity(0.1), lineWidth: 0.5) + .blendMode(.plusLighter) + } + } +} + +struct LocalPath { + var path: String + + init(path: String = #file) { + self.path = path + } + + var url: URL { + URL(fileURLWithPath: path) + } +} + +// *Really* minimal PCG32 code / (c) 2014 M.E. O'Neill / pcg-random.org +// Licensed under Apache License 2.0 (NO WARRANTY, etc. see website) +// +// Ported from https://www.pcg-random.org/download.html +private struct MinimalPCG { + var state: UInt64 + + var inc: UInt64 + + init(string: String) { + self.state = string.utf8.reduce(0.0) { a, b in a + (Double(b) * .pi) }.bitPattern + self.inc = (Double(string.count) * .pi).bitPattern + } + + init(state: UInt64, inc: UInt64) { + self.state = state + self.inc = inc + } + + mutating func next() -> UInt32 { + let oldstate = state + + // Advance internal state + state = oldstate &* 6364136223846793005 &+ (inc | 1) + // Calculate output function (XSH RR), uses old state for max ILP + let xorshifted = ((oldstate >> 18) ^ oldstate) >> 27 + let rot = Int(truncatingIfNeeded: oldstate >> 59) + + return UInt32(truncatingIfNeeded: (xorshifted >> rot) | (xorshifted << ((-rot) & 31))) + } +} diff --git a/Example/Pow Example/Examples/Screens/CheckoutExample.swift b/Example/Pow Example/Examples/Screens/CheckoutExample.swift new file mode 100644 index 0000000..22ef47d --- /dev/null +++ b/Example/Pow Example/Examples/Screens/CheckoutExample.swift @@ -0,0 +1,275 @@ +import Pow +import SwiftUI + +struct CheckoutExample: View, Example { + enum PaymentError: Error { + case unknown + } + + @State + var result: Result? + + @State + var quantity = 1 + + var body: some View { + List { + if case .success = result { + VStack(alignment: .leading) { + Text("Thank You For Your Order") + .font(.title2) + .bold() + Text("We'll notify you when your order has been sent.") + .font(.title3) + .foregroundStyle(.secondary) + } + .listRowSeparator(.hidden) + + Spacer() + + LabeledContent("Purchase Number", value: "P023121114") + } else { + VStack(alignment: .leading) { + Text("Checkout") + .font(.largeTitle) + .bold() + .accessibility(addTraits: .isHeader) + Text(quantity > 0 ? "1 Item" : "No Items") + .font(.title2) + .foregroundStyle(.secondary) + } + .listRowSeparator(.hidden) + + Spacer() + + Section { + if quantity > 0 { + CartItem(quantity: $quantity) + } + } + } + + Spacer() + + Section { + LabeledContent("Address", value: "jane.doe@example.com") + + LabeledContent("Payment", value: "VISA") + } + } + .listStyle(.plain) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .animation(.default, value: quantity != 0) + .toolbar { + if quantity == 0 { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Undo") { + quantity = 1 + } + } + } + } + .changeEffect(.feedback(SoundEffect("whop")), value: quantity == 0, isEnabled: quantity == 0) + .changeEffect(.feedback(SoundEffect("wip")), value: quantity != 0, isEnabled: quantity != 0) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 0) { + LabeledContent("Subtotal", value: 99 * quantity, format: .currency(code: "USD")) + .foregroundStyle(.secondary) + LabeledContent("Shipping", value: 0, format: .currency(code: "USD")) + .foregroundStyle(.secondary) + LabeledContent("Total", value: 99 * quantity, format: .currency(code: "USD")) + .fontWeight(.heavy) + } + + PayButton { + let isFirstPayAttempt = result == nil + + try? await Task.sleep(nanoseconds: 1_000_000_000) + + if isFirstPayAttempt { + throw PaymentError.unknown + } + } completion: { payResult in + withAnimation { + result = payResult + } + } + .disabled(quantity == 0) + .changeEffect(.shine.delay(1), value: quantity != 0, isEnabled: quantity != 0) + } + .padding() + .background(.bar) + } + .labeledContentStyle(CheckoutLabeledContentStyle()) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "cart") + } + + static let newIn0_2_0: Bool = true +} + +private struct CartItem: View { + @Binding + var quantity: Int + + @State + var lastQuantity: Int = 0 + + var body: some View { + HStack { + Stepper("Quantity", value: $quantity, in: 0...99) + .labelsHidden() + .alignmentGuide(.listRowSeparatorLeading) { dimensions in + dimensions[.leading] + } + .changeEffect(.feedback(SoundEffect("beep")), value: quantity, isEnabled: quantity > lastQuantity && quantity > 1) + .changeEffect(.feedback(SoundEffect("boop")), value: quantity, isEnabled: quantity < lastQuantity && quantity > 0) + .onChange(of: quantity) { newValue in + lastQuantity = quantity + } + + Text(quantity, format: .number).monospacedDigit() + Text("×") + + LabeledContent("Pow License", value: 99, format: .currency(code: "USD")) + } + } +} + +struct PayButton: View { + var action: () async throws -> Void + + var completion: (Result) -> Void + + enum Status { + case initial + case inProgress + case succeeded + case failed + } + + @State + var status: Status = .initial + + var body: some View { + Button { + status = .inProgress + Task { + do { + try await action() + status = .succeeded + completion(.success(())) + } catch { + status = .failed + try? await Task.sleep(nanoseconds: 1_500_000_000) + status = .initial + completion(.failure(error)) + } + } + } label: { + HStack(spacing: 12) { + ZStack { + ProgressView() + .controlSize(.regular) + .tint(.white) + .opacity(status == .inProgress ? 1 : 0) + .animation(.spring(), value: status == .inProgress) + + Image(systemName: "exclamationmark.triangle") + .opacity(status == .failed ? 1 : 0) + .animation(.spring(), value: status == .failed) + + Checkmark() + .trim(from: 0, to: status == .succeeded ? 1 : 0) + .stroke(style: .init(lineWidth: 3, lineCap: .round, lineJoin: .round)) + .padding(4) + .animation(.spring(response: 0.3), value: status == .succeeded) + } + .frame(width: 20, height: 20) + .imageScale(.large) + + Spacer() + + switch status { + case .initial: + Text("Pay") + case .inProgress: + Text("Paying…") + case .succeeded: + Text("Paid") + case .failed: + Text("Try Again") + } + + Color.clear + .frame(width: 20, height: 20) + + Spacer() + } + } + .font(.headline) + .buttonStyle(.borderedProminent) + .transformEnvironment(\.backgroundMaterial, transform: { material in + material = nil + }) + .controlSize(.large) + .animation(.spring(response: 0.3), value: status == .inProgress) + .tint(status == .failed ? .red : status == .succeeded ? .green : nil) + .allowsHitTesting(status == .initial) + .changeEffect(.shake(rate: .fast), value: status == .failed, isEnabled: status == .failed) + .changeEffect(.feedback(SoundEffect("plop")), value: status == .inProgress, isEnabled: status == .inProgress) + .changeEffect(.feedback(SoundEffect("sparkle")), value: status == .succeeded, isEnabled: status == .succeeded) + .changeEffect(.feedback(SoundEffect("notfound")), value: status == .failed, isEnabled: status == .failed) + } +} + +private struct Checkmark: Shape { + func path(in rect: CGRect) -> Path { + let insetFrame = rect + + let referenceSize = CGSize(width: 67, height: 68) + let referencePoint1: CGPoint + let referencePoint2: CGPoint + let referencePoint3: CGPoint + + referencePoint1 = CGPoint(x: 3.5, y: 36.5) + referencePoint2 = CGPoint(x: 25.5, y: 63.5) + referencePoint3 = CGPoint(x: 63, y: 5.5) + + return Path { path in + path.move(to: CGPoint(x: insetFrame.width * referencePoint1.x / referenceSize.width, y: insetFrame.height * referencePoint1.y / referenceSize.height)) + path.addLine(to: CGPoint(x: insetFrame.width * referencePoint2.x / referenceSize.width, y: insetFrame.width * referencePoint2.y / referenceSize.height)) + path.addLine(to: CGPoint(x: insetFrame.width * referencePoint3.x / referenceSize.width, y: insetFrame.width * referencePoint3.y / referenceSize.height)) + } + .offsetBy(dx: insetFrame.origin.x, dy: insetFrame.origin.y) + } +} + +private struct CheckoutLabeledContentStyle: LabeledContentStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: .firstTextBaseline) { + configuration.label + .font(.headline) + Spacer() + configuration.content + .font(.body) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } +} + +struct CheckoutExample_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + CheckoutExample() + .toolbar(.visible, for: .navigationBar) + } + } +} diff --git a/Example/Pow Example/Examples/Screens/SocialFeedExample.swift b/Example/Pow Example/Examples/Screens/SocialFeedExample.swift new file mode 100644 index 0000000..d819cd3 --- /dev/null +++ b/Example/Pow Example/Examples/Screens/SocialFeedExample.swift @@ -0,0 +1,261 @@ +import Pow +import SwiftUI + +struct SocialFeedExample: View, Example { + @State + var isLiked = false + + @State + var isBoosted = false + + @State + var clapCount = 202 + + @State + var isBookmarked = false + + @State + var notificationCount: Int = 0 + + var body: some View { + ScrollView { + VStack(alignment: .trailing, spacing: 24) { + HStack(alignment: .top, spacing: 12) { + Image("mvp") + .resizable() + .aspectRatio(contentMode: .fill) + .clipShape(Circle()) + .frame(width: 52, height: 52) + + VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + Text("Moving Parts").font(.headline.bold()) + Spacer() + + Text("12 min") + .font(.footnote) + .foregroundColor(.secondary) + } + Text("@movingpartsio").foregroundColor(.secondary) + } + .padding(.top, 3) + + Text("Use Pow's Change Effects to give your buttons a little extra flair.") + + Text("Try it on these buttons here:") + } + } + + ViewThatFits { + buttonBar + .labelStyle(CustomButtonLabelStyle()) + + buttonBar + .labelStyle(.iconOnly) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .tint(.gray) + .font(.footnote.weight(.medium).monospacedDigit()) + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(12) + } + .task { + try? await Task.sleep(for: .seconds(3)) + + withAnimation { + notificationCount += 1 + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + tabBar + } + .navigationBarTitleDisplayMode(.inline) + } + + var buttonBar: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + let pop = SoundEffect("pop1", "pop2", "pop3", "pop4", "pop5") + + Button { + isLiked.toggle() + } label: { + let likeCount = isLiked ? 144: 143 + + Label { + Text(likeCount.formatted()) + } icon: { + Image(systemName: "heart.fill") + .changeEffect( + .spray { + Image(systemName: "heart.fill").foregroundStyle(.red) + }, + value: likeCount, + isEnabled: isLiked + ) + } + } + .tint(isLiked ? .red : .gray) + .changeEffect(.feedback(pop), value: isLiked, isEnabled: isLiked) + + let sparkle = SoundEffect(isBoosted ? "sparkle.rising" : "sparkle.falling") + + Button { + isBoosted.toggle() + } label: { + let boostCount = isBoosted ? 55 : 54 + + Label { + Text(boostCount.formatted()) + } icon: { + Image(systemName: "arrow.3.trianglepath") + .changeEffect( + .rise { + Text("+1") + .font(.footnote.weight(.heavy)) + .shadow(color: .green.opacity(0.5), radius: 1) + .foregroundStyle(.green.gradient) + }, + value: boostCount, + isEnabled: isBoosted + ) + } + } + .tint(isBoosted ? .green : .gray) + .changeEffect(.feedback(sparkle), value: isBoosted) + + Button { + clapCount += 1 + } label: { + Label { + Text(clapCount.formatted()) + } icon: { + Image(systemName: "hands.clap.fill") + .changeEffect( + .spray { + Group { + Image(systemName: "circle.fill").foregroundColor(.red) + Image(systemName: "square.fill").foregroundColor(.green) + Image(systemName: "circle.fill").foregroundColor(.blue) + Image(systemName: "diamond.fill").foregroundColor(.orange) + Image(systemName: "triangle.fill").foregroundColor(.indigo) + } + .shadow(radius: 1) + .font(.caption.weight(.black)) + }, + value: clapCount + ) + } + } + .changeEffect(.feedback(pop), value: clapCount) + .tint(clapCount > 202 ? .blue : .gray) + + let pick = SoundEffect(isBookmarked ? "pick.rising" : "pick.falling") + + Button { + isBookmarked.toggle() + } label: { + Label { + ZStack { + Text("Saved").hidden() + Text(isBookmarked ? "Saved" : "Save") + } + } icon: { + Image(systemName: "bookmark.fill") + } + } + .tint(isBookmarked ? .orange : .gray) + .animation(.spring(response: 0.4, dampingFraction: 1), value: isBookmarked) + .changeEffect(.feedback(pick), value: isBookmarked) + } + } + + var tabBar: some View { + HStack { + Label("Home", systemImage: "house") + .labelStyle(SocialFeedTabBarLabelStyle(isSelected: true)) + + Label("Search", systemImage: "magnifyingglass") + + Label { + Text("Notifications") + } icon: { + Image(systemName: "bell") + .overlay(alignment: .topTrailing) { + Text(notificationCount.formatted()) + .fixedSize() + .font(.caption.monospacedDigit()) + .foregroundColor(.white) + .padding(.vertical, 2) + .padding(.horizontal, 7) + .background(.red, in: Capsule()) + .changeEffect(.pulse(shape: Capsule(), style: .red, count: 3), value: notificationCount) + .alignmentGuide(.top) { dimensions in + dimensions[VerticalAlignment.center] - 2 + } + .alignmentGuide(.trailing) { dimensions in + dimensions[HorizontalAlignment.center] + } + .scaleEffect(notificationCount > 0 ? 1 : 0.1) + .opacity(notificationCount > 0 ? 1 : 0) + } + } + .onTapGesture { + withAnimation { + notificationCount += 1 + } + } + + Label { + Text("Archive") + } icon: { + Image(systemName: "archivebox") + .changeEffect(.jump(height: 50), value: isBookmarked, isEnabled: isBookmarked) + } + + Label("Profile", systemImage: "person") + } + .labelStyle(SocialFeedTabBarLabelStyle(isSelected: false)) + .padding(12) + .padding(.bottom, 2) + .background(.regularMaterial, in: Capsule(style: .continuous)) + .padding(.horizontal) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "heart") + } +} + +private struct SocialFeedTabBarLabelStyle: LabelStyle { + var isSelected: Bool + + func makeBody(configuration: Configuration) -> some View { + VStack(spacing: 6) { + configuration.icon + .imageScale(.medium) + .symbolVariant(isSelected ? .fill : .none) + .font(.system(size: 22)) + } + .foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.primary)) + .frame(maxWidth: .infinity) + } +} + +private struct CustomButtonLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 4) { + configuration.icon + + configuration.title + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .frame(maxWidth: .infinity) + } +} diff --git a/Example/Pow Example/Examples/Transitions/AnvilExample.swift b/Example/Pow Example/Examples/Transitions/AnvilExample.swift new file mode 100644 index 0000000..229bfc9 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/AnvilExample.swift @@ -0,0 +1,29 @@ +import Pow +import SwiftUI + +struct AnvilExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition(.movingParts.anvil) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "scalemass") + } +} diff --git a/Example/Pow Example/Examples/Transitions/BlindsExample.swift b/Example/Pow Example/Examples/Transitions/BlindsExample.swift new file mode 100644 index 0000000..8d6adfe --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/BlindsExample.swift @@ -0,0 +1,29 @@ +import Pow +import SwiftUI + +struct BlindsExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition(.movingParts.blinds) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "blinds.horizontal.open") + } +} diff --git a/Example/Pow Example/Examples/Transitions/BlurExample.swift b/Example/Pow Example/Examples/Transitions/BlurExample.swift new file mode 100644 index 0000000..fb4ba3c --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/BlurExample.swift @@ -0,0 +1,29 @@ +import Pow +import SwiftUI + +struct BlurExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition(.movingParts.blur.combined(with: .opacity)) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "drop") + } +} diff --git a/Example/Pow Example/Examples/Transitions/BoingExample.swift b/Example/Pow Example/Examples/Transitions/BoingExample.swift new file mode 100644 index 0000000..fc4b6f7 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/BoingExample.swift @@ -0,0 +1,59 @@ +import Pow +import SwiftUI + +struct BoingExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + HStack { + if isVisible { + let defaultSpring = Animation.spring() + + PlaceholderView() + .frame(maxWidth: 120, maxHeight: 120) + .transition( + .asymmetric( + insertion: .movingParts.boing(edge: .top).animation(defaultSpring), + removal: .movingParts.boing(edge: .top).animation(defaultSpring).combined(with: .opacity.animation(.easeInOut(duration: 0.2))) + ) + ) + + let mediumSpring = Animation.interactiveSpring(dampingFraction: 0.5) + + PlaceholderView() + .frame(maxWidth: 120, maxHeight: 120) + .transition( + .asymmetric( + insertion: .movingParts.boing(edge: .top).animation(mediumSpring), + removal: .movingParts.boing(edge: .top).animation(mediumSpring).combined(with: .opacity.animation(.easeInOut(duration: 0.2))) + ) + ) + + let looseSpring = Animation.interpolatingSpring(stiffness: 100, damping: 8) + + PlaceholderView() + .frame(maxWidth: 120, maxHeight: 120) + .transition( + .asymmetric( + insertion: .movingParts.boing(edge: .top).animation(looseSpring), + removal: .movingParts.boing(edge: .top).animation(looseSpring).combined(with: .opacity.animation(.easeInOut(duration: 0.2))) + ) + ) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "figure.jumprope") + } +} diff --git a/Example/Pow Example/Examples/Transitions/ClockExample.swift b/Example/Pow Example/Examples/Transitions/ClockExample.swift new file mode 100644 index 0000000..af52f56 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/ClockExample.swift @@ -0,0 +1,29 @@ +import Pow +import SwiftUI + +struct ClockExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition(.movingParts.clock(blurRadius: 10)) + } + } + .defaultBackground() + .onTapGesture { + withAnimation(.spring(dampingFraction: 1)) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: .spring(dampingFraction: 1)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "clock") + } +} diff --git a/Example/Pow Example/Examples/Transitions/FilmExposureExample.swift b/Example/Pow Example/Examples/Transitions/FilmExposureExample.swift new file mode 100644 index 0000000..281b527 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/FilmExposureExample.swift @@ -0,0 +1,40 @@ +import Pow +import SwiftUI + +struct FilmExposureExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + ZStack { + // Placeholder + Rectangle().fill(.black) + + if isVisible { + Image("disco") + .resizable() + .zIndex(1) + .transition(.movingParts.filmExposure) + } else { + ProgressView() + .tint(.white) + } + } + .frame(width: 350, height: 525) + } + .defaultBackground() + .onTapGesture { + withAnimation(.easeInOut(duration: 1.8)) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: .easeInOut(duration: 1.8)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "film") + } +} diff --git a/Example/Pow Example/Examples/Transitions/FlickerExample.swift b/Example/Pow Example/Examples/Transitions/FlickerExample.swift new file mode 100644 index 0000000..5becae6 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/FlickerExample.swift @@ -0,0 +1,29 @@ +import Pow +import SwiftUI + +struct FlickerExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition(.movingParts.flicker) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "lightbulb") + } +} diff --git a/Example/Pow Example/Examples/Transitions/FlipExample.swift b/Example/Pow Example/Examples/Transitions/FlipExample.swift new file mode 100644 index 0000000..776ea56 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/FlipExample.swift @@ -0,0 +1,79 @@ +import Pow +import SwiftUI + +struct FlipExample: View, Example { + enum Variant: Hashable { + case flip + case standUp + case sideways + + var transition: AnyTransition { + switch self { + case .flip: return .movingParts.flip + case .standUp: return .movingParts.rotate3D(.degrees(90), axis: (1, 0, 0), anchor: .bottom, perspective: 1 / 6) + case .sideways: return .movingParts.rotate3D(.degrees(90), axis: (0, 1, 0), perspective: 1 / 6) + } + } + } + + @State + var variant: Variant = .flip + + @State + var isVisible: Bool = false + + var body: some View { + VStack { + GroupBox { + LabeledContent("Configuration") { + Picker("Configuration", selection: $variant) { + Text("Flip").tag(Variant.flip) + Text("Sideways").tag(Variant.sideways) + Text("Stand Up").tag(Variant.standUp) + } + } + } + .padding(.horizontal) + + VStack { + if isVisible { + PlaceholderView() + .id(variant) + .transition(variant.transition) + } + } + .frame(maxHeight: .infinity) + .defaultBackground() + .onTapGesture { + withAnimation(animation) { + isVisible.toggle() + } + } + } + .defaultBackground() + .onChange(of: variant) { _ in + withAnimation(animation) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: animation) + } + + var animation: Animation { + if isVisible { + return .easeIn + } else { + return .interactiveSpring(response: 0.4, dampingFraction: 0.4, blendDuration: 2.45) + } + } + + static var title: String { + "Flip & Rotate3D" + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "rotate.3d") + } +} diff --git a/Example/Pow Example/Examples/Transitions/GlareExample.swift b/Example/Pow Example/Examples/Transitions/GlareExample.swift new file mode 100644 index 0000000..db7b72d --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/GlareExample.swift @@ -0,0 +1,39 @@ +import Pow +import SwiftUI + +struct GlareExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition( + .asymmetric( + insertion: .movingParts.glare(angle: .degrees(225), color: .white), + removal: .movingParts.glare(angle: .degrees(45), color: .white) + .animation(.movingParts.easeInExponential(duration: 0.9)) + .combined(with: + .scale(scale: 1.4) + .animation(.movingParts.anticipate(duration: 0.9).delay(0.1)) + ) + ) + ) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "sun.max") + } +} diff --git a/Example/Pow Example/Examples/Transitions/IrisExample.swift b/Example/Pow Example/Examples/Transitions/IrisExample.swift new file mode 100644 index 0000000..887b823 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/IrisExample.swift @@ -0,0 +1,30 @@ +import Pow +import SwiftUI + +struct IrisExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .compositingGroup() + .transition(.movingParts.iris(blurRadius: 10)) + } + } + .defaultBackground() + .onTapGesture { + withAnimation(.spring(dampingFraction: 1)) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: .spring(dampingFraction: 1)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "camera.aperture") + } +} diff --git a/Example/Pow Example/Examples/Transitions/MoveExample.swift b/Example/Pow Example/Examples/Transitions/MoveExample.swift new file mode 100644 index 0000000..8b31f74 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/MoveExample.swift @@ -0,0 +1,62 @@ +import Pow +import SwiftUI + +struct MoveExample: View, Example { + @State + var angle: Angle = .degrees(225) + + @State + var isVisible: Bool = false + + var body: some View { + VStack { + GroupBox { + LabeledContent { + Slider(value: $angle.degrees, in: 0 ... 360, step: 5) + } label: { + Text("Angle") + Spacer() + Text(Measurement(value: angle.degrees, unit: UnitAngle.degrees).formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0))))) + .foregroundColor(.secondary) + .font(.subheadline.monospacedDigit()) + } + } + .padding(.horizontal) + + VStack { + if isVisible { + PlaceholderView() + .compositingGroup() + .transition(.movingParts.move(angle: angle).combined(with: .opacity)) + } + } + .defaultBackground() + .onTapGesture { + withAnimation(.spring(dampingFraction: 1)) { + isVisible.toggle() + } + } + } + .labeledContentStyle(VerticalLabeledContentStyle()) + .defaultBackground() + .autotoggle($isVisible, with: .spring(dampingFraction: 1)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.up.left.and.down.right.and.arrow.up.right.and.down.left") + } +} + +private struct VerticalLabeledContentStyle: LabeledContentStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + configuration.label + } + + configuration.content + } + } +} diff --git a/Example/Pow Example/Examples/Transitions/PoofExample.swift b/Example/Pow Example/Examples/Transitions/PoofExample.swift new file mode 100644 index 0000000..e8ffd46 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/PoofExample.swift @@ -0,0 +1,35 @@ +import Pow +import SwiftUI + +struct PoofExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .compositingGroup() + // Assign a random ID so that quick re-insertion will not + // play the poof transition backwards. + .id(UUID()) + .transition( + .asymmetric(insertion: .opacity, removal: .movingParts.poof) + ) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "trash") + } +} diff --git a/Example/Pow Example/Examples/Transitions/PopExample.swift b/Example/Pow Example/Examples/Transitions/PopExample.swift new file mode 100644 index 0000000..b71ce20 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/PopExample.swift @@ -0,0 +1,44 @@ +import Pow +import SwiftUI + +struct PopExample: View, Example { + @State + var isFavorited: Bool = false + + var body: some View { + ZStack { + HStack { + if isFavorited { + Image(systemName: "heart.fill") + .foregroundColor(.red) + .transition( + .movingParts.pop(.red) + ) + } else { + Image(systemName: "heart") + .foregroundColor(.gray) + .transition(.identity) + } + + let favoriteCount = isFavorited ? 143 : 142 + + Text(favoriteCount.formatted()) + .foregroundColor(isFavorited ? .red : .gray) + .animation(isFavorited ? .default.delay(0.4) : nil, value: isFavorited) + } + } + .defaultBackground() + .onTapGesture { + withAnimation(.spring(dampingFraction: 1)) { + isFavorited.toggle() + } + } + .autotoggle($isFavorited, with: .spring(dampingFraction: 1)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "rays") + } +} diff --git a/Example/Pow Example/Examples/Transitions/SkidExample.swift b/Example/Pow Example/Examples/Transitions/SkidExample.swift new file mode 100644 index 0000000..27fdd80 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/SkidExample.swift @@ -0,0 +1,44 @@ +import Pow +import SwiftUI + +struct SkidExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + VStack { + if isVisible { + let overshoot = Animation.movingParts.overshoot(duration: 0.8) + + PlaceholderView() + .frame(maxWidth: 120, maxHeight: 120) + .transition(.movingParts.skid(direction: .leading).animation(overshoot)) + + let mediumSpring = Animation.interactiveSpring(dampingFraction: 0.5) + + PlaceholderView() + .frame(maxWidth: 120, maxHeight: 120) + .transition(.movingParts.skid.animation(mediumSpring)) + + let looseSpring = Animation.interpolatingSpring(stiffness: 100, damping: 8) + + PlaceholderView() + .frame(maxWidth: 120, maxHeight: 120) + .transition(.movingParts.skid.animation(looseSpring)) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "arrow.left.and.right.square") + } +} diff --git a/Example/Pow Example/Examples/Transitions/SnapshotExample.swift b/Example/Pow Example/Examples/Transitions/SnapshotExample.swift new file mode 100644 index 0000000..1a81e7a --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/SnapshotExample.swift @@ -0,0 +1,38 @@ +import Pow +import SwiftUI + +struct SnapshotExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + ZStack { + // Placeholder + Rectangle() + .fill(.white) + + if isVisible { + Image("disco") + .resizable() + .zIndex(1) + .transition(.movingParts.snapshot) + } + } + .frame(width: 350, height: 525) + } + .defaultBackground() + .onTapGesture { + withAnimation(.easeInOut(duration: 1.8)) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: .easeInOut(duration: 1.8)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "camera") + } +} diff --git a/Example/Pow Example/Examples/Transitions/SwooshExample.swift b/Example/Pow Example/Examples/Transitions/SwooshExample.swift new file mode 100644 index 0000000..dcef9e9 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/SwooshExample.swift @@ -0,0 +1,37 @@ +import Pow +import SwiftUI + +struct SwooshExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + .transition(.movingParts.swoosh.combined(with: .opacity)) + } + } + .defaultBackground() + .onTapGesture { + let animation: Animation + + if isVisible { + animation = .easeIn + } else { + animation = .spring() + } + + withAnimation(animation) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: .spring()) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "skew") + } +} diff --git a/Example/Pow Example/Examples/Transitions/VanishExample.swift b/Example/Pow Example/Examples/Transitions/VanishExample.swift new file mode 100644 index 0000000..1314a44 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/VanishExample.swift @@ -0,0 +1,38 @@ +import Pow +import SwiftUI + +struct VanishExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + Circle() + .frame(width: 250, height: 250) + // Assign a random ID so that quick re-insertion will not + // play the vanish transition backwards. + .id(UUID()) + .transition( + .asymmetric( + insertion: .opacity, + removal: .movingParts.vanish(Color(white: 0.8), mask: Circle()) + ) + ) + } + } + .defaultBackground() + .onTapGesture { + withAnimation { + isVisible.toggle() + } + } + .autotoggle($isVisible) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "circle.dotted") + } +} diff --git a/Example/Pow Example/Examples/Transitions/WipeExample.swift b/Example/Pow Example/Examples/Transitions/WipeExample.swift new file mode 100644 index 0000000..7d70988 --- /dev/null +++ b/Example/Pow Example/Examples/Transitions/WipeExample.swift @@ -0,0 +1,37 @@ +import Pow +import SwiftUI + +struct WipeExample: View, Example { + @State + var isVisible: Bool = false + + var body: some View { + ZStack { + if isVisible { + PlaceholderView() + // Assign a random ID so that quick re-insertion will not + // play the wipe transition backwards. + .id(UUID()) + .transition( + .asymmetric( + insertion: .movingParts.wipe(angle: .degrees(235), blurRadius: 30), + removal: .movingParts.wipe(angle: .degrees(55), blurRadius: 30) + ) + ) + } + } + .defaultBackground() + .onTapGesture { + withAnimation(.spring(dampingFraction: 1)) { + isVisible.toggle() + } + } + .autotoggle($isVisible, with: .spring(dampingFraction: 1)) + } + + static let localPath = LocalPath() + + static var icon: Image? { + Image(systemName: "windshield.rear.and.wiper") + } +} diff --git a/Example/Pow Example/GithubButton.swift b/Example/Pow Example/GithubButton.swift new file mode 100644 index 0000000..7e46c00 --- /dev/null +++ b/Example/Pow Example/GithubButton.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct GithubButton: View { + var localPath: LocalPath + + let baseURL = URL(string: "https://github.com/movingparts-io/Pow-Examples/blob/main/")! + + init(_ localPath: LocalPath) { + self.localPath = localPath + } + + var body: some View { + let srcroot = Bundle.main.object(forInfoDictionaryKey: "MVP_SRCROOT") as? String + + if let srcURL = srcroot.map(URL.init(fileURLWithPath:)) { + let relative = localPath.url.relativePath(to: srcURL) + + let url = baseURL.appendingPathComponent(relative) + + Link(destination: url) { + ViewThatFits { + Label("Show Example on GitHub", systemImage: "terminal") + Label("Show on GitHub", systemImage: "terminal") + } + } + } + } +} + +private extension URL { + func relativePath(to base: URL) -> String { + let pathComponents = self.pathComponents + let baseComponents = base.pathComponents + + guard pathComponents.starts(with: baseComponents) else { + fatalError("\(self) is not contained inside \(base).") + } + + return pathComponents + .dropFirst(baseComponents.count) + .joined(separator: "/") + } +} diff --git a/Example/Pow Example/PlaceholderView.swift b/Example/Pow Example/PlaceholderView.swift new file mode 100644 index 0000000..0155333 --- /dev/null +++ b/Example/Pow Example/PlaceholderView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct PlaceholderView: View { + var hiddenContent: Bool + + init(hiddenContent: Bool = false) { + self.hiddenContent = hiddenContent + } + + var gridLines: some View { + ZStack { + Circle() + .stroke(lineWidth: 1) + .padding() + Circle() + .stroke(lineWidth: 1) + .padding() + .padding() + .padding() + .padding() + HStack { + ForEach(0..<5) { _ in + Spacer() + Rectangle().frame(width: 1) + } + Spacer() + } + VStack { + Spacer() + Rectangle().frame(height: 1) + Spacer() + Rectangle().frame(height: 1) + Spacer() + Rectangle().frame(height: 1) + Spacer() + Rectangle().frame(height: 1) + Spacer() + } + } + .overlay { + Rectangle().frame(width: 1, height: 500) + .rotationEffect(.degrees(45)) + Rectangle().frame(width: 1, height: 500) + .rotationEffect(.degrees(-45)) + } + } + + var fillColors: [Color] { + if !hiddenContent { + return [ + Color(.displayP3, red: 0.32, green: 0.61, blue: 0.97), + Color(.displayP3, red: 0.20, green: 0.47, blue: 0.96) + ] + } else { + return [ + Color(.displayP3, white: 0.25), + Color(.displayP3, white: 0.3) + ] + } + } + + @ViewBuilder + var fill: some View { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(LinearGradient(colors: fillColors, startPoint: .top, endPoint: .bottom)) + .overlay { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .strokeBorder(.black.opacity(0.3), lineWidth: 4) + } + } + + var body: some View { + fill + .overlay { + gridLines + .opacity(0.25) + .scaledToFill() + } + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font( + Font + .system(.largeTitle) + .bold() + .leading(.tight) + ) + .multilineTextAlignment(.center) + .environment(\.dynamicTypeSize, .xxLarge) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .compositingGroup() + .frame(maxWidth: 250, maxHeight: 250) + } +} diff --git a/Example/Pow Example/PowExampleApp.swift b/Example/Pow Example/PowExampleApp.swift new file mode 100644 index 0000000..5ccf099 --- /dev/null +++ b/Example/Pow Example/PowExampleApp.swift @@ -0,0 +1,40 @@ +import SwiftUI + +@main +struct PowExampleApp: App { + struct Presentation: Identifiable { + var type: any Example.Type + + var id: UUID = UUID() + } + + @State + var presentedType: Presentation? = nil + + var body: some Scene { + WindowGroup { + NavigationStack { + ExampleList() + } + .environment(\.presentInfoAction, PresentInfoAction { + presentedType = Presentation(type: $0) + }) + .sheet(item: $presentedType) { t in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(t.type.title).font(.title.bold()) + + GithubButton(t.type.localPath) + .controlSize(.small) + .buttonStyle(.bordered) + + t.type.erasedDescription + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .presentationDetents([.medium]) + } + } + } +} diff --git a/Example/Pow Example/Preview Content/Preview Assets.xcassets/Contents.json b/Example/Pow Example/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Pow Example/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Pow Example/Sounds/beep.m4a b/Example/Pow Example/Sounds/beep.m4a new file mode 100644 index 0000000..356767a Binary files /dev/null and b/Example/Pow Example/Sounds/beep.m4a differ diff --git a/Example/Pow Example/Sounds/biip.m4a b/Example/Pow Example/Sounds/biip.m4a new file mode 100644 index 0000000..81925dc Binary files /dev/null and b/Example/Pow Example/Sounds/biip.m4a differ diff --git a/Example/Pow Example/Sounds/boop.m4a b/Example/Pow Example/Sounds/boop.m4a new file mode 100644 index 0000000..3072cf5 Binary files /dev/null and b/Example/Pow Example/Sounds/boop.m4a differ diff --git a/Example/Pow Example/Sounds/brush.m4a b/Example/Pow Example/Sounds/brush.m4a new file mode 100644 index 0000000..cc75a5d Binary files /dev/null and b/Example/Pow Example/Sounds/brush.m4a differ diff --git a/Example/Pow Example/Sounds/chime.falling.m4a b/Example/Pow Example/Sounds/chime.falling.m4a new file mode 100644 index 0000000..0a13d9b Binary files /dev/null and b/Example/Pow Example/Sounds/chime.falling.m4a differ diff --git a/Example/Pow Example/Sounds/chime.flat.m4a b/Example/Pow Example/Sounds/chime.flat.m4a new file mode 100644 index 0000000..1d37fa8 Binary files /dev/null and b/Example/Pow Example/Sounds/chime.flat.m4a differ diff --git a/Example/Pow Example/Sounds/chime.m4a b/Example/Pow Example/Sounds/chime.m4a new file mode 100644 index 0000000..0fa5471 Binary files /dev/null and b/Example/Pow Example/Sounds/chime.m4a differ diff --git a/Example/Pow Example/Sounds/chime.rising.m4a b/Example/Pow Example/Sounds/chime.rising.m4a new file mode 100644 index 0000000..642989b Binary files /dev/null and b/Example/Pow Example/Sounds/chime.rising.m4a differ diff --git a/Example/Pow Example/Sounds/detach.m4a b/Example/Pow Example/Sounds/detach.m4a new file mode 100644 index 0000000..75acd27 Binary files /dev/null and b/Example/Pow Example/Sounds/detach.m4a differ diff --git a/Example/Pow Example/Sounds/dial.m4a b/Example/Pow Example/Sounds/dial.m4a new file mode 100644 index 0000000..2bc7443 Binary files /dev/null and b/Example/Pow Example/Sounds/dial.m4a differ diff --git a/Example/Pow Example/Sounds/drip.falling.m4a b/Example/Pow Example/Sounds/drip.falling.m4a new file mode 100644 index 0000000..9687b87 Binary files /dev/null and b/Example/Pow Example/Sounds/drip.falling.m4a differ diff --git a/Example/Pow Example/Sounds/drip.flat.m4a b/Example/Pow Example/Sounds/drip.flat.m4a new file mode 100644 index 0000000..70d93c5 Binary files /dev/null and b/Example/Pow Example/Sounds/drip.flat.m4a differ diff --git a/Example/Pow Example/Sounds/drip.m4a b/Example/Pow Example/Sounds/drip.m4a new file mode 100644 index 0000000..4101bdb Binary files /dev/null and b/Example/Pow Example/Sounds/drip.m4a differ diff --git a/Example/Pow Example/Sounds/drip.rising.m4a b/Example/Pow Example/Sounds/drip.rising.m4a new file mode 100644 index 0000000..8c1618f Binary files /dev/null and b/Example/Pow Example/Sounds/drip.rising.m4a differ diff --git a/Example/Pow Example/Sounds/glass.m4a b/Example/Pow Example/Sounds/glass.m4a new file mode 100644 index 0000000..5e02d1a Binary files /dev/null and b/Example/Pow Example/Sounds/glass.m4a differ diff --git a/Example/Pow Example/Sounds/latch1.m4a b/Example/Pow Example/Sounds/latch1.m4a new file mode 100644 index 0000000..ff39d19 Binary files /dev/null and b/Example/Pow Example/Sounds/latch1.m4a differ diff --git a/Example/Pow Example/Sounds/latch2.m4a b/Example/Pow Example/Sounds/latch2.m4a new file mode 100644 index 0000000..7a95d34 Binary files /dev/null and b/Example/Pow Example/Sounds/latch2.m4a differ diff --git a/Example/Pow Example/Sounds/latch3.m4a b/Example/Pow Example/Sounds/latch3.m4a new file mode 100644 index 0000000..fcbe289 Binary files /dev/null and b/Example/Pow Example/Sounds/latch3.m4a differ diff --git a/Example/Pow Example/Sounds/latch4.m4a b/Example/Pow Example/Sounds/latch4.m4a new file mode 100644 index 0000000..fa735c3 Binary files /dev/null and b/Example/Pow Example/Sounds/latch4.m4a differ diff --git a/Example/Pow Example/Sounds/lock1.m4a b/Example/Pow Example/Sounds/lock1.m4a new file mode 100644 index 0000000..1cc6199 Binary files /dev/null and b/Example/Pow Example/Sounds/lock1.m4a differ diff --git a/Example/Pow Example/Sounds/lock2.m4a b/Example/Pow Example/Sounds/lock2.m4a new file mode 100644 index 0000000..340b31f Binary files /dev/null and b/Example/Pow Example/Sounds/lock2.m4a differ diff --git a/Example/Pow Example/Sounds/lock3.m4a b/Example/Pow Example/Sounds/lock3.m4a new file mode 100644 index 0000000..ce98a84 Binary files /dev/null and b/Example/Pow Example/Sounds/lock3.m4a differ diff --git a/Example/Pow Example/Sounds/lock4.m4a b/Example/Pow Example/Sounds/lock4.m4a new file mode 100644 index 0000000..4f8c3bd Binary files /dev/null and b/Example/Pow Example/Sounds/lock4.m4a differ diff --git a/Example/Pow Example/Sounds/notfound.m4a b/Example/Pow Example/Sounds/notfound.m4a new file mode 100644 index 0000000..4133363 Binary files /dev/null and b/Example/Pow Example/Sounds/notfound.m4a differ diff --git a/Example/Pow Example/Sounds/pick.falling.m4a b/Example/Pow Example/Sounds/pick.falling.m4a new file mode 100644 index 0000000..3a59fbf Binary files /dev/null and b/Example/Pow Example/Sounds/pick.falling.m4a differ diff --git a/Example/Pow Example/Sounds/pick.flat.m4a b/Example/Pow Example/Sounds/pick.flat.m4a new file mode 100644 index 0000000..1d57c0e Binary files /dev/null and b/Example/Pow Example/Sounds/pick.flat.m4a differ diff --git a/Example/Pow Example/Sounds/pick.m4a b/Example/Pow Example/Sounds/pick.m4a new file mode 100644 index 0000000..d5e3f4a Binary files /dev/null and b/Example/Pow Example/Sounds/pick.m4a differ diff --git a/Example/Pow Example/Sounds/pick.rising.m4a b/Example/Pow Example/Sounds/pick.rising.m4a new file mode 100644 index 0000000..850fd49 Binary files /dev/null and b/Example/Pow Example/Sounds/pick.rising.m4a differ diff --git a/Example/Pow Example/Sounds/ping.m4a b/Example/Pow Example/Sounds/ping.m4a new file mode 100644 index 0000000..f220910 Binary files /dev/null and b/Example/Pow Example/Sounds/ping.m4a differ diff --git a/Example/Pow Example/Sounds/plop.m4a b/Example/Pow Example/Sounds/plop.m4a new file mode 100644 index 0000000..593f4e8 Binary files /dev/null and b/Example/Pow Example/Sounds/plop.m4a differ diff --git a/Example/Pow Example/Sounds/pluck.m4a b/Example/Pow Example/Sounds/pluck.m4a new file mode 100644 index 0000000..0e2d4f5 Binary files /dev/null and b/Example/Pow Example/Sounds/pluck.m4a differ diff --git a/Example/Pow Example/Sounds/pong.m4a b/Example/Pow Example/Sounds/pong.m4a new file mode 100644 index 0000000..eced0bd Binary files /dev/null and b/Example/Pow Example/Sounds/pong.m4a differ diff --git a/Example/Pow Example/Sounds/pop1.m4a b/Example/Pow Example/Sounds/pop1.m4a new file mode 100644 index 0000000..205efdc Binary files /dev/null and b/Example/Pow Example/Sounds/pop1.m4a differ diff --git a/Example/Pow Example/Sounds/pop2.m4a b/Example/Pow Example/Sounds/pop2.m4a new file mode 100644 index 0000000..9af6ae9 Binary files /dev/null and b/Example/Pow Example/Sounds/pop2.m4a differ diff --git a/Example/Pow Example/Sounds/pop3.m4a b/Example/Pow Example/Sounds/pop3.m4a new file mode 100644 index 0000000..aa6c764 Binary files /dev/null and b/Example/Pow Example/Sounds/pop3.m4a differ diff --git a/Example/Pow Example/Sounds/pop4.m4a b/Example/Pow Example/Sounds/pop4.m4a new file mode 100644 index 0000000..94a37ef Binary files /dev/null and b/Example/Pow Example/Sounds/pop4.m4a differ diff --git a/Example/Pow Example/Sounds/pop5.m4a b/Example/Pow Example/Sounds/pop5.m4a new file mode 100644 index 0000000..2f02f9d Binary files /dev/null and b/Example/Pow Example/Sounds/pop5.m4a differ diff --git a/Example/Pow Example/Sounds/reel.falling.m4a b/Example/Pow Example/Sounds/reel.falling.m4a new file mode 100644 index 0000000..2d46dfa Binary files /dev/null and b/Example/Pow Example/Sounds/reel.falling.m4a differ diff --git a/Example/Pow Example/Sounds/reel.flat.m4a b/Example/Pow Example/Sounds/reel.flat.m4a new file mode 100644 index 0000000..5189270 Binary files /dev/null and b/Example/Pow Example/Sounds/reel.flat.m4a differ diff --git a/Example/Pow Example/Sounds/reel.m4a b/Example/Pow Example/Sounds/reel.m4a new file mode 100644 index 0000000..71a5f1c Binary files /dev/null and b/Example/Pow Example/Sounds/reel.m4a differ diff --git a/Example/Pow Example/Sounds/reel.rising.m4a b/Example/Pow Example/Sounds/reel.rising.m4a new file mode 100644 index 0000000..e710872 Binary files /dev/null and b/Example/Pow Example/Sounds/reel.rising.m4a differ diff --git a/Example/Pow Example/Sounds/shake.m4a b/Example/Pow Example/Sounds/shake.m4a new file mode 100644 index 0000000..1006996 Binary files /dev/null and b/Example/Pow Example/Sounds/shake.m4a differ diff --git a/Example/Pow Example/Sounds/snap.m4a b/Example/Pow Example/Sounds/snap.m4a new file mode 100644 index 0000000..427db4a Binary files /dev/null and b/Example/Pow Example/Sounds/snap.m4a differ diff --git a/Example/Pow Example/Sounds/sparkle.falling.m4a b/Example/Pow Example/Sounds/sparkle.falling.m4a new file mode 100644 index 0000000..e96e026 Binary files /dev/null and b/Example/Pow Example/Sounds/sparkle.falling.m4a differ diff --git a/Example/Pow Example/Sounds/sparkle.flat.m4a b/Example/Pow Example/Sounds/sparkle.flat.m4a new file mode 100644 index 0000000..9f3c2b8 Binary files /dev/null and b/Example/Pow Example/Sounds/sparkle.flat.m4a differ diff --git a/Example/Pow Example/Sounds/sparkle.m4a b/Example/Pow Example/Sounds/sparkle.m4a new file mode 100644 index 0000000..f5276ae Binary files /dev/null and b/Example/Pow Example/Sounds/sparkle.m4a differ diff --git a/Example/Pow Example/Sounds/sparkle.rising.m4a b/Example/Pow Example/Sounds/sparkle.rising.m4a new file mode 100644 index 0000000..dd6752b Binary files /dev/null and b/Example/Pow Example/Sounds/sparkle.rising.m4a differ diff --git a/Example/Pow Example/Sounds/swipe.m4a b/Example/Pow Example/Sounds/swipe.m4a new file mode 100644 index 0000000..8370c23 Binary files /dev/null and b/Example/Pow Example/Sounds/swipe.m4a differ diff --git a/Example/Pow Example/Sounds/swish.m4a b/Example/Pow Example/Sounds/swish.m4a new file mode 100644 index 0000000..ea05385 Binary files /dev/null and b/Example/Pow Example/Sounds/swish.m4a differ diff --git a/Example/Pow Example/Sounds/tick.m4a b/Example/Pow Example/Sounds/tick.m4a new file mode 100644 index 0000000..172e636 Binary files /dev/null and b/Example/Pow Example/Sounds/tick.m4a differ diff --git a/Example/Pow Example/Sounds/tink.m4a b/Example/Pow Example/Sounds/tink.m4a new file mode 100644 index 0000000..becbf2d Binary files /dev/null and b/Example/Pow Example/Sounds/tink.m4a differ diff --git a/Example/Pow Example/Sounds/tock.m4a b/Example/Pow Example/Sounds/tock.m4a new file mode 100644 index 0000000..51b8744 Binary files /dev/null and b/Example/Pow Example/Sounds/tock.m4a differ diff --git a/Example/Pow Example/Sounds/whop.m4a b/Example/Pow Example/Sounds/whop.m4a new file mode 100644 index 0000000..eb55abc Binary files /dev/null and b/Example/Pow Example/Sounds/whop.m4a differ diff --git a/Example/Pow Example/Sounds/wip.m4a b/Example/Pow Example/Sounds/wip.m4a new file mode 100644 index 0000000..654f2da Binary files /dev/null and b/Example/Pow Example/Sounds/wip.m4a differ diff --git a/Example/Pow Example/Sounds/zing.m4a b/Example/Pow Example/Sounds/zing.m4a new file mode 100644 index 0000000..a1bfd75 Binary files /dev/null and b/Example/Pow Example/Sounds/zing.m4a differ diff --git a/Example/Pow-Example-Info.plist b/Example/Pow-Example-Info.plist new file mode 100644 index 0000000..24e3241 --- /dev/null +++ b/Example/Pow-Example-Info.plist @@ -0,0 +1,17 @@ + + + + + MVP_SRCROOT + $(SRCROOT) + ITSAppUsesNonExemptEncryption + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + + diff --git a/Example/README.md b/Example/README.md new file mode 100644 index 0000000..69153da --- /dev/null +++ b/Example/README.md @@ -0,0 +1,10 @@ +# Pow Examples + +| ![Example Overview](/Example/Screenshots/screenshot0.png) | ![Screenshot 1](/Example/Screenshots/screenshot1.png) | ![Screenshot 2](/Example/Screenshots/screenshot2.png) | ![Screenshot 3](/Example/Screenshots/screenshot3.png)| +|-|-|-|-| + +### [✈️ **Join TestFlight Beta**](https://testflight.apple.com/join/oLZvCpXT) + +This repo contains examples for the [Pow effects framework for SwiftUI](https://movingparts.io/pow). + +You can find additional previews as well as licensing information of all effects on [the Pow website](https://movingparts.io/pow). For more a more in-depth explanation of the individual APIs, consult [the README in the Pow GitHub repo](https://github.com/movingparts-io/Pow). diff --git a/Example/Screenshots/screenshot0.png b/Example/Screenshots/screenshot0.png new file mode 100644 index 0000000..0774e42 Binary files /dev/null and b/Example/Screenshots/screenshot0.png differ diff --git a/Example/Screenshots/screenshot1.png b/Example/Screenshots/screenshot1.png new file mode 100644 index 0000000..679d326 Binary files /dev/null and b/Example/Screenshots/screenshot1.png differ diff --git a/Example/Screenshots/screenshot2.png b/Example/Screenshots/screenshot2.png new file mode 100644 index 0000000..ba13948 Binary files /dev/null and b/Example/Screenshots/screenshot2.png differ diff --git a/Example/Screenshots/screenshot3.png b/Example/Screenshots/screenshot3.png new file mode 100644 index 0000000..d9f26b7 Binary files /dev/null and b/Example/Screenshots/screenshot3.png differ diff --git a/LICENSE b/LICENSE index cc423bb..b0d56df 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,21 @@ -Copyright 2022 Moving Parts MVP, Inc. All rights reserved. +MIT License -Please check out https://movingparts.io/pow to find out about licensing Pow for you app. +Copyright (c) 2023 Emerge Tools, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Package.swift b/Package.swift index f1c24bc..86cd4ff 100644 --- a/Package.swift +++ b/Package.swift @@ -1,24 +1,33 @@ -// swift-tools-version:5.7 +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + import PackageDescription let package = Package( name: "Pow", platforms: [ .iOS(.v15), - .macCatalyst(.v15), .macOS(.v12), + .macCatalyst(.v15) ], products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "Pow", targets: ["Pow"]), ], - dependencies: [], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], targets: [ - .binaryTarget( + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( name: "Pow", - url: "https://packages.movingparts.io/binaries/pow/0.3.1/Pow.xcframework.zip", - checksum: "d69a6023276202aeaca0e35e552647d95fece7a365af5bc243e264287ff75b68" - ), + dependencies: []), + .testTarget( + name: "PowTests", + dependencies: ["Pow"]), ] ) diff --git a/README.md b/README.md index a23d7ab..369fe92 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ ![](./images/og-image.png) -

-:mag: Pow Overview | -:octocat: Example App Repo -

- # Pow Delightful SwiftUI effects for your app. -> **Note** -> Pow is free for commercial and non-commercial use. - # Installation -To add a package dependency to your Xcode project, select _File_ > _Add Package_ and enter this repository's URL (https://github.com/movingparts-io/Pow). +To add a package dependency to your Xcode project, select _File_ > _Add Package_ and enter this repository's URL (https://github.com/EmergeTools/Pow). + +To add a package dependency to Swift Package, add this repository to your list of dependencies. +```swift +.package(url: "https://github.com/EmergeTools/Pow", from: Version(1, 0, 0)) +``` + +And to your target as a product: +```swift +.product(name: "Pow", package: "Pow") +``` + +If you are moving from the previously closed source Pow framework to the new open source package, please refer to our [Transition Guide](). If you have any problems please file an [issue](https://github.com/EmergeTools/Pow/issues). # Overview @@ -22,6 +26,20 @@ Pow features a selection of [SwiftUI transitions](#transitions) as well as [Chan You can find previews of all effects [on the Pow website](https://movingparts.io/pow). If you have an iOS Developer Environment, you can check out the [Pow Example App](https://github.com/movingparts-io/Pow-Examples). +# Feedback & Contribution + +This project provides multiple forms of delivering feedback to maintainers. + +If you are figuring out how to use about Pow or one of it's effects we ask that you first consult the [effects examples page](https://movingparts.io/pow). + +If you still have a question, enhancement, or a way to improve Pow, this project leverages GitHub's [Issues](https://github.com/EmergeTools/Pow/issues) to manage your requests. If you find a bug and wish to report it, an issue would be greatly appreciated. + +# Requirements + +- iOS 15.0+ +- macOS 12.0 +- Mac Catalyst 15.0+ + ## Change Effects Change Effects are effects that will trigger a visual or haptic every time a value changes. @@ -38,7 +56,7 @@ Button { .tint(post.isLiked ? .red : .gray) ``` -You can choose from the following Change Effects: [Spray](#spray), [Glow](#glow), [Haptic Feedback](#haptic-feedback), [Jump](#jump), [Pulse](#pulse), [Rise](#rise), [Shake](#shake), [Shine](#shine), [Spin](#spin), and [Wiggle](#wiggle). +You can choose from the following Change Effects: [Spray](#spray), [Haptic Feedback](#haptic-feedback), [Jump](#jump), [Ping](#ping), [Rise](#rise), [Shake](#shake), [Shine](#shine), and [Spin](#spin). ### Spray @@ -63,35 +81,6 @@ likeButton static func spray(origin: UnitPoint = .center, layer: ParticleLayer = .local, @ViewBuilder _ particles: () -> some View) -> AnyChangeEffect ``` -### Glow - -[Preview](https://movingparts.io/pow/#glow) - -An effect that highlights the view with a glow around it. - -The glow appears for a second. - -```swift -Text(price, format: .currency(code: "USD") - .changeEffect(.glow, value: price) -``` - -```swift -static var glow: AnyConditionalEffect -``` - -An effect that highlights the view with a glow around it. - -The glow appears for a second. - -- Parameters: - - `color`: The color of the glow. - - `radius`: The radius of the glow. - -```swift -static func glow(color: Color, radius: CGFloat = 16) -> AnyChangeEffect -``` - ### Haptic Feedback Triggers haptic feedback to communicate successes, failures, and warnings whenever a value changes. @@ -128,21 +117,31 @@ Makes the view jump the given height and then bounces a few times before settlin static func jump(height: CGFloat) -> AnyChangeEffect ``` -### Pulse +### Ping [Preview](https://movingparts.io/pow/#ping) -An effect that adds one or more shapes that slowly grow and fade-out behind the view. +Adds one or more shapes that slowly grow and fade-out behind the view. + +The shape will be colored by the current tint style. - Parameters: - `shape`: The shape to use for the effect. - - `style`: The shape style to use for the effect. Defaults to `tint`. - - `drawingMode`: The mode used to render the shape. Defaults to `fill`. - - `count`: The number of shapes to emit. Defaults to `1`. - - `layer`: The `ParticleLayer` on which to render the effect. Defaults to `local`. + - `count`: The number of shapes to emit. + +```swift + static func ping(shape: some InsettableShape, count: Int) -> AnyChangeEffect +``` + + An effect that adds one or more shapes that slowly grow and fade-out behind the view. + + - Parameters: + - `shape`: The shape to use for the effect. + - `style`: The style to use for the effect. + - `count`: The number of shapes to emit. ```swift -static func pulse(shape: some InsettableShape, style: some ShapeStyle = .tint, drawingMode: PulseDrawingMode = .fill, count: Int = 1, layer: ParticleLayer = .local) -> AnyChangeEffect +static func ping(shape: some InsettableShape, style: some ShapeStyle, count: Int) -> AnyChangeEffect ``` ### Rise @@ -214,7 +213,7 @@ static func shine(angle: Angle, duration: Double = 1.0) -> AnyChangeEffect Triggers a sound effect as feedback whenever a value changes. -This effect will not interrupt or duck any other audio that may currently be playing. It may also not be triggered based on the setting of the user's silent switch or playback device. +This effect will not interrupt or duck any other audio that may currently playing. It may also not triggered based on the setting of the user's silent switch or playback device. To relay important information to the user, you should always accompany audio effects with visual cues. @@ -237,32 +236,13 @@ static var spin: AnyChangeEffect Spins the view around the given axis when a change happens. - Parameters: - - `axis`: The x, y and z elements that specify the axis of rotation. - - `anchor`: The location with a default of center that defines a point in 3D space about which the rotation is anchored. - - `anchorZ`: The location with a default of 0 that defines a point in 3D space about which the rotation is anchored. - - `perspective`: The relative vanishing point with a default of 1 / 6 for this rotation. - - `rate`: The rate of the spin. - -```swift -static func spin(axis: (x: CGFloat, y: CGFloat, z: CGFloat), anchor: UnitPoint = .center, anchorZ: CGFloat = 0, perspective: CGFloat = 1 / 6, rate: SpinRate = .default) -> AnyChangeEffect -``` - -### Wiggle - -[Preview](https://movingparts.io/pow/#wiggle) - -An effect that wiggles the view when a change happens. - -```swift -static var wiggle: AnyChangeEffect -``` - -An effect that wiggles the view when a change happens. - -- `rate`: The rate of the wiggle. + - axis: The x, y and z elements that specify the axis of rotation. + - anchor: The location with a default of center that defines a point in 3D space about which the rotation is anchored. + - anchorZ: The location with a default of 0 that defines a point in 3D space about which the rotation is anchored. + - perspective: The relative vanishing point with a default of 1 / 6 for this rotation. ```swift -static func wiggle(rate: WiggleRate) -> AnyChangeEffect +static func spin(axis: (x: CGFloat, y: CGFloat, z: CGFloat), anchor: UnitPoint = .center, anchorZ: CGFloat = 0, perspective: CGFloat = 1 / 6) -> AnyChangeEffect ``` ### Delay @@ -285,112 +265,6 @@ Button("Submit") { func delay(_ delay: Double) -> AnyChangeEffect ``` -## Conditional Effects - -Conditional Effects are effects that can be enabled or disabled through a boolean flag. - -Use the `conditionalEffect` modifier and pass in an `AnyConditionalEffect` as well as a condition to enable the effect. - -```swift -Button { - playlist.writeToDisc() -} label: { - Label(playlist.isWritingToDisc ? "Burning…" : "Burn", systemName: "opticaldisc.fill") -} -.conditionalEffect(.smoke, condition: playlist.isWritingToDisc) -``` - -You can choose from the following Conditional Effects: [Smoke](#smoke), [Push Down](#push-down), and [Glow](#glow-1). - -Change Effects can be used with the [Repeat](#repeat) modifier. - -### Smoke - -[Preview](https://movingparts.io/pow/#smoke) - -An effect that emits smoke from the view. - -```swift -burnButton - .conditionalEffect(.smoke, condition: isOn) -``` - -```swift -static var smoke: AnyConditionalEffect -``` - -An effect that emits smoke from the view. - -- Parameters: - - `layer`: The `ParticleLayer` on which to render the effect, default is `local`. - -```swift -static func smoke(layer: ParticleLayer = .local) -> AnyConditionalEffect -``` - -### Push Down - -An effect that pushes down the view while a condition is true. - -```swift -submitButton - .conditionalEffect(.pushDown, condition: isPressed) -``` - -```swift -static var pushDown: AnyConditionalEffect -``` - -### Glow - -[Preview](https://movingparts.io/pow/#glow) - -An effect that highlights the view with a glow around it. - -```swift -continueButton - .conditionalEffect(.pushDown, condition: canContinue) -``` - -```swift -static var glow: AnyConditionalEffect -``` - -An effect that highlights the view with a glow around it. - -- Parameters: - - `color`: The color of the glow. - - `radius`: The radius of the glow. - -```swift -static func glow(color: Color, radius: CGFloat = 16) -> AnyConditionalEffect -``` - -### Repeat - -Repeats a change effect at the specified interval while a condition is true. - -```swift -notificationsTabView - .conditionalEffect(.repeat(.jump(height: 100), every: 2), condition: hasUnreadMessages) -``` - -- Parameters: - - `effect`: The change effect to repeat. - - `interval`: The duration between each change effect. - -```swift -static func `repeat`(_ effect: AnyChangeEffect, every interval: Duration) -> AnyConditionalEffect -``` - -- Parameters: - - `effect`: The change effect to repeat. - - `interval`: The number of seconds between each change effect. - -```swift -static func `repeat`(_ effect: AnyChangeEffect, every interval: TimeInterval) -> AnyConditionalEffect -``` - ## Particle Layer A particle layer is a context in which particle effects draw their particles. @@ -457,15 +331,6 @@ on removal. static var blur: AnyTransition ``` -A transition from blurry to sharp on insertion, and from sharp to blurry -on removal. - -- Parameter `radius`: The radial size of the blur at the end of the transition. - -```swift -static func blur(radius: CGFloat) -> AnyTransition -``` - ### Boing [Preview](https://movingparts.io/pow/#boing) diff --git a/Sources/Pow/Assets.xcassets/Contents.json b/Sources/Pow/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Pow/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/anvil_smoke_gray.imageset/AnvilSmokeLight.png b/Sources/Pow/Assets.xcassets/anvil_smoke_gray.imageset/AnvilSmokeLight.png new file mode 100644 index 0000000..fcc9430 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/anvil_smoke_gray.imageset/AnvilSmokeLight.png differ diff --git a/Sources/Pow/Assets.xcassets/anvil_smoke_gray.imageset/Contents.json b/Sources/Pow/Assets.xcassets/anvil_smoke_gray.imageset/Contents.json new file mode 100644 index 0000000..a05310c --- /dev/null +++ b/Sources/Pow/Assets.xcassets/anvil_smoke_gray.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AnvilSmokeLight.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/anvil_smoke_white.imageset/AnvilSmokeDark.png b/Sources/Pow/Assets.xcassets/anvil_smoke_white.imageset/AnvilSmokeDark.png new file mode 100644 index 0000000..4fcfe62 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/anvil_smoke_white.imageset/AnvilSmokeDark.png differ diff --git a/Sources/Pow/Assets.xcassets/anvil_smoke_white.imageset/Contents.json b/Sources/Pow/Assets.xcassets/anvil_smoke_white.imageset/Contents.json new file mode 100644 index 0000000..7fefd09 --- /dev/null +++ b/Sources/Pow/Assets.xcassets/anvil_smoke_white.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AnvilSmokeDark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/poof1.imageset/Contents.json b/Sources/Pow/Assets.xcassets/poof1.imageset/Contents.json new file mode 100644 index 0000000..0f1540f --- /dev/null +++ b/Sources/Pow/Assets.xcassets/poof1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "poof1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "poof1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "poof1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/poof1.imageset/poof1.png b/Sources/Pow/Assets.xcassets/poof1.imageset/poof1.png new file mode 100644 index 0000000..ae08a03 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof1.imageset/poof1.png differ diff --git a/Sources/Pow/Assets.xcassets/poof1.imageset/poof1@2x.png b/Sources/Pow/Assets.xcassets/poof1.imageset/poof1@2x.png new file mode 100644 index 0000000..c233055 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof1.imageset/poof1@2x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof1.imageset/poof1@3x.png b/Sources/Pow/Assets.xcassets/poof1.imageset/poof1@3x.png new file mode 100644 index 0000000..48b5166 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof1.imageset/poof1@3x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof2.imageset/Contents.json b/Sources/Pow/Assets.xcassets/poof2.imageset/Contents.json new file mode 100644 index 0000000..44fb39d --- /dev/null +++ b/Sources/Pow/Assets.xcassets/poof2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "poof2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "poof2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "poof2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/poof2.imageset/poof2.png b/Sources/Pow/Assets.xcassets/poof2.imageset/poof2.png new file mode 100644 index 0000000..dbd1a05 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof2.imageset/poof2.png differ diff --git a/Sources/Pow/Assets.xcassets/poof2.imageset/poof2@2x.png b/Sources/Pow/Assets.xcassets/poof2.imageset/poof2@2x.png new file mode 100644 index 0000000..a01debe Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof2.imageset/poof2@2x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof2.imageset/poof2@3x.png b/Sources/Pow/Assets.xcassets/poof2.imageset/poof2@3x.png new file mode 100644 index 0000000..2e1d6f2 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof2.imageset/poof2@3x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof3.imageset/Contents.json b/Sources/Pow/Assets.xcassets/poof3.imageset/Contents.json new file mode 100644 index 0000000..6c1cac7 --- /dev/null +++ b/Sources/Pow/Assets.xcassets/poof3.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "poof3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "poof3@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "poof3@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/poof3.imageset/poof3.png b/Sources/Pow/Assets.xcassets/poof3.imageset/poof3.png new file mode 100644 index 0000000..c494e10 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof3.imageset/poof3.png differ diff --git a/Sources/Pow/Assets.xcassets/poof3.imageset/poof3@2x.png b/Sources/Pow/Assets.xcassets/poof3.imageset/poof3@2x.png new file mode 100644 index 0000000..0042f18 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof3.imageset/poof3@2x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof3.imageset/poof3@3x.png b/Sources/Pow/Assets.xcassets/poof3.imageset/poof3@3x.png new file mode 100644 index 0000000..a8d303e Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof3.imageset/poof3@3x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof4.imageset/Contents.json b/Sources/Pow/Assets.xcassets/poof4.imageset/Contents.json new file mode 100644 index 0000000..89542ec --- /dev/null +++ b/Sources/Pow/Assets.xcassets/poof4.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "poof4.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "poof4@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "poof4@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/poof4.imageset/poof4.png b/Sources/Pow/Assets.xcassets/poof4.imageset/poof4.png new file mode 100644 index 0000000..dff88b4 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof4.imageset/poof4.png differ diff --git a/Sources/Pow/Assets.xcassets/poof4.imageset/poof4@2x.png b/Sources/Pow/Assets.xcassets/poof4.imageset/poof4@2x.png new file mode 100644 index 0000000..9008703 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof4.imageset/poof4@2x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof4.imageset/poof4@3x.png b/Sources/Pow/Assets.xcassets/poof4.imageset/poof4@3x.png new file mode 100644 index 0000000..5ab8dc5 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof4.imageset/poof4@3x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof5.imageset/Contents.json b/Sources/Pow/Assets.xcassets/poof5.imageset/Contents.json new file mode 100644 index 0000000..1529893 --- /dev/null +++ b/Sources/Pow/Assets.xcassets/poof5.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "poof5.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "poof5@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "poof5@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Pow/Assets.xcassets/poof5.imageset/poof5.png b/Sources/Pow/Assets.xcassets/poof5.imageset/poof5.png new file mode 100644 index 0000000..ed318c5 Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof5.imageset/poof5.png differ diff --git a/Sources/Pow/Assets.xcassets/poof5.imageset/poof5@2x.png b/Sources/Pow/Assets.xcassets/poof5.imageset/poof5@2x.png new file mode 100644 index 0000000..895161a Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof5.imageset/poof5@2x.png differ diff --git a/Sources/Pow/Assets.xcassets/poof5.imageset/poof5@3x.png b/Sources/Pow/Assets.xcassets/poof5.imageset/poof5@3x.png new file mode 100644 index 0000000..b4d05af Binary files /dev/null and b/Sources/Pow/Assets.xcassets/poof5.imageset/poof5@3x.png differ diff --git a/Sources/Pow/Effects/GlowEffect.swift b/Sources/Pow/Effects/GlowEffect.swift new file mode 100644 index 0000000..14bed01 --- /dev/null +++ b/Sources/Pow/Effects/GlowEffect.swift @@ -0,0 +1,316 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that highlights the view with a glow around it. + /// + /// The glow appears for a second. + static var glow: AnyChangeEffect { + glow(color: .accentColor) + } + + /// An effect that highlights the view with a glow around it. + /// + /// The glow appears for a second. + /// + /// - Parameters: + /// - color: The color of the glow. + /// - radius: The radius of the glow. + static func glow(color: Color, radius: CGFloat = 16) -> AnyChangeEffect { + .simulation { change in + PulseGlowModifier(impulseCount: change, color: color, radius: min(100, radius)) + } + } +} + +public extension AnyConditionalEffect { + /// An effect that highlights the view with a glow around it. + static var glow: AnyConditionalEffect { + glow(color: .accentColor) + } + + /// An effect that highlights the view with a glow around it. + /// + /// - Parameters: + /// - color: The color of the glow. + /// - radius: The radius of the glow. + static func glow(color: Color, radius: CGFloat = 16) -> AnyConditionalEffect { + .continuous( + .modifier { isActive in + ContinuousGlowModifier(color: color, radius: radius, isActive: isActive) + } + ) + } +} + +internal struct GlowModifier: ViewModifier, Animatable { + var animatableData: CGFloat + + var color: Color + + var radius: CGFloat + + let ramp = cubicBezier(x1: 0.3, y1: 0.0, x2: 0.7, y2: 1) + + init(glow: CGFloat, color: Color, radius: CGFloat) { + self.animatableData = glow + self.color = color + self.radius = radius + } + + var glow: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + let amount = min(glow, 1.5) + + let shadowOpacity = sqrt(amount) + + content + .transformEnvironment(\.backgroundMaterial) { material in + material = nil + } + .overlay { + color + .opacity(ramp(amount)) + .blendMode(.sourceAtop) + .brightness(ramp(abs(amount)) * 0.1) + } + .compositingGroup() + .shadow(color: color.opacity(shadowOpacity / 1.2), radius: amount * radius / 4.0, x: 0, y: 0) + .shadow(color: color.opacity(shadowOpacity / 4.0), radius: amount * radius / 2.0, x: 0, y: 0) + .shadow(color: color.opacity(shadowOpacity / 8.0), radius: amount * radius, x: 0, y: 0) + .shadow(color: color.opacity(shadowOpacity / 16.0), radius: amount * radius * 2.0, x: 0, y: 0) + .brightness(ramp(abs(amount)) * 0.25) + .animation(nil, value: amount) + } +} + +internal struct ContinuousGlowModifier: ViewModifier, Continuous { + var color: Color + + var radius: CGFloat + + var isActive: Bool + + init(color: Color, radius: CGFloat, isActive: Bool) { + self.color = color + self.radius = radius + self.isActive = isActive + } + + func body(content: Content) -> some View { + content + .modifier( + GlowModifier(glow: isActive ? 0.7 : 0, color: color, radius: radius) + .animation(.easeInOut(duration: 0.25)) + ) + } +} + +internal struct PulseGlowModifier: ViewModifier, Simulative { + var impulseCount: Int + + var initialVelocity: CGFloat = 0 + + let spring = Spring(zeta: 0.75, stiffness: 15, mass: 1) + + var color: Color + + var radius: CGFloat + + @State + private var targetGlow: CGFloat = 0.0 + + @State + private var glow: CGFloat = 0.0 + + @State + private var glowVelocity: CGFloat = 0.0 + + private var isSimulationPaused: Bool { + targetGlow == glow && abs(glowVelocity) <= 0.02 + } + + internal func body(content: Content) -> some View { + TimelineView(.animation(paused: isSimulationPaused)) { context in + content + .modifier(GlowModifier(glow: glow, color: color, radius: radius)) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(clamp(0, duration, 1 / 30)) + } + } + } + .onChange(of: impulseCount) { newValue in + withAnimation(nil) { + if glowVelocity <= 0.05 { + glowVelocity = 5 + } else { + glowVelocity += 1.5 + } + + glowVelocity = min(glowVelocity, 5) + } + } + } + + private func update(_ step: Double) { + let newValue: Double + let newVelocity: Double + + if spring.response > 0 { + (newValue, newVelocity) = spring.value( + from: glow, + to: targetGlow, + velocity: glowVelocity, + timestep: step + ) + } else { + newValue = targetGlow + newVelocity = 0.0 + } + + glow = newValue + glowVelocity = newVelocity + + if abs(newValue - targetGlow) < 0.01, newVelocity < 0.01 { + glow = targetGlow + glowVelocity = 0.0 + } + } +} + +#if os(iOS) && DEBUG +struct GlowChangeEffect_Previews: PreviewProvider { + struct Cart: View { + @State + var itemCount: Int = 1 + + var total: Double { + 9.99 * Double(itemCount) + } + + var body: some View { + List { + HStack(alignment: .center, spacing: 16) { + AsyncImage(url: URL(string: "https://movingparts.io/frontpage/checkout-smooth-blend@3x.png")) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + } + } + .background(Color(white: 0.9)) + .frame(width: 72, height: 72) + .changeEffect(.shine(angle: .degrees(180), duration: 0.5), value: itemCount, isEnabled: itemCount > 0) + + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 8) { + Text("Seasonal Blend, Spring Here") + .font(.body.weight(.medium)) + .lineSpacing(-10) + + Text("500g") + .font(.callout) + .foregroundColor(.secondary) + } + Spacer() + + VStack(alignment: .trailing) { + Text("\(itemCount.formatted())× ").foregroundColor(.secondary) + + Text(9.99.formatted(.currency(code: "EUR"))) + Stepper(value: $itemCount, in: 0...1000) { + Text("Quantity ") + Text(itemCount.formatted()).foregroundColor(.secondary) + } + .labelsHidden() + .font(.callout) + } + .font(.callout) + } + } + } + .listStyle(.plain) + .navigationTitle("Cart") + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack { + if #available(iOS 16.0, *) { + LabeledContent("Subtotal", value: total, format: .currency(code: "USD")) + LabeledContent("Shipping", value: 0, format: .currency(code: "USD")) + LabeledContent("Total") { + Text(total, format: .currency(code: "USD")) + .foregroundStyle(.primary) + .changeEffect(.glow(color: .accentColor, radius: 32), value: itemCount) + } + .tint(.red) + .bold() + } + + Divider().hidden() + + Button { + } label: { + Label("Checkout", systemImage: "cart") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(itemCount == 0) + .animation(.default, value: itemCount == 0) + } + .monospacedDigit() + .padding() + .background(.regularMaterial) + } + } + } + + struct Preview: View { + @State + var isOn = false + + var body: some View { + VStack { + Spacer() + + Button("Continue") { + + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .conditionalEffect(.repeat(.glow(color: .blue, radius: 50), every: 1.5), condition: isOn) + + Button("Continue") { + + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .conditionalEffect(.glow(color: .blue, radius: 50), condition: isOn) + + Spacer() + + Toggle("Enabled", isOn: $isOn) + } + .padding() + } + } + + static var previews: some View { + NavigationView { + Cart() + } + .preferredColorScheme(.dark) + .environment(\.dynamicTypeSize, .xxLarge) + .previewDisplayName("Change Effect") + + Preview() + .preferredColorScheme(.dark) + .environment(\.dynamicTypeSize, .xxLarge) + .previewDisplayName("Conditional Effect") + } +} +#endif diff --git a/Sources/Pow/Effects/HapticFeedbackEffect.swift b/Sources/Pow/Effects/HapticFeedbackEffect.swift new file mode 100644 index 0000000..4c5dfe5 --- /dev/null +++ b/Sources/Pow/Effects/HapticFeedbackEffect.swift @@ -0,0 +1,78 @@ +#if os(iOS) +import SwiftUI + +public extension AnyChangeEffect { + /// Triggers haptic feedback whenever a value changes. + /// + /// - Parameter type: The feedback type to trigger. + @available(*, deprecated, renamed: "feedback(hapticNotification:)") + static func hapticFeedback(_ type: UINotificationFeedbackGenerator.FeedbackType) -> AnyChangeEffect { + feedback(hapticNotification: type) + } + + /// Triggers haptic feedback to communicate successes, failures, and warnings whenever a value changes. + /// + /// - Parameter notification: The feedback type to trigger. + static func feedback(hapticNotification type: UINotificationFeedbackGenerator.FeedbackType) -> AnyChangeEffect { + .simulation { change in + HapticFeedbackEffect(feedback: .notification(type), impulseCount: change) + } + } + + /// Triggers haptic feedback to simulate physical impacts whenever a value changes. + /// + /// - Parameter impact: The feedback style to trigger. + static func feedback(hapticImpact style: UIImpactFeedbackGenerator.FeedbackStyle) -> AnyChangeEffect { + .simulation { change in + HapticFeedbackEffect(feedback: .impact(style), impulseCount: change) + } + } + + /// Triggers haptic feedback to indicate a change in selection whenever a value changes. + static var feedbackHapticSelection: AnyChangeEffect { + .simulation { change in + HapticFeedbackEffect(feedback: .selection, impulseCount: change) + } + } +} + +internal struct HapticFeedbackEffect: ViewModifier, Simulative { + var impulseCount: Int = 0 + + // TODO: Remove from protocol + var initialVelocity: CGFloat = 0 + + enum FeedbackType { + case notification(UINotificationFeedbackGenerator.FeedbackType) + case impact(UIImpactFeedbackGenerator.FeedbackStyle) + case selection + } + + var feedbackType: FeedbackType + + init(feedback: FeedbackType, impulseCount: Int) { + self.feedbackType = feedback + self.impulseCount = impulseCount + } + + func body(content: Content) -> some View { + content + .onChange(of: impulseCount) { _ in + switch feedbackType { + case .notification(let type): + let generator = UINotificationFeedbackGenerator() + + generator.notificationOccurred(type) + case .impact(let style): + let generator = UIImpactFeedbackGenerator(style: style) + + generator.impactOccurred() + case .selection: + let generator = UISelectionFeedbackGenerator() + + generator.selectionChanged() + } + } + } +} +#endif diff --git a/Sources/Pow/Effects/JumpEffect.swift b/Sources/Pow/Effects/JumpEffect.swift new file mode 100644 index 0000000..2120418 --- /dev/null +++ b/Sources/Pow/Effects/JumpEffect.swift @@ -0,0 +1,380 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that makes the view jump. + /// + /// - Parameter height: The height of the jump. + static func jump(height: CGFloat) -> AnyChangeEffect { + .simulation { change in + JumpSimulationModifier(height: height, impulseCount: change) + } + } +} + +internal struct JumpSimulationModifier: ViewModifier, Simulative { + var impulseCount: Int + + var initialVelocity: CGFloat = 0 + + private let spring = Spring(zeta: 1 / 3, stiffness: 100 * 1) + + @State + private var displacement: CGFloat = .zero + + @State + private var velocity: CGFloat = 0.0 + + @State + private var jumpBuffered: Bool = false + + #if os(iOS) + @State + private var feedbackGenerator: UIImpactFeedbackGenerator? + #endif + + private var isSimulationPaused: Bool { + velocity.isZero + } + + private var targetHeight: Double + + init(height: Double, impulseCount: Int) { + self.impulseCount = impulseCount + + precondition(spring.zeta < 1, "Spring must be underdamped") + + let peakTime = spring.peakTime(initialPosition: 0, initialVelocity: 1) + let peakHeight = spring.value(initialPosition: 0, initialVelocity: 1, at: peakTime) + + self.initialVelocity = -(height / peakHeight) + self.targetHeight = height + } + + public func body(content: Content) -> some View { + TimelineView(.animation(paused: isSimulationPaused)) { context in + content + .modifier(SquishOffset(displacement: displacement)) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(max(0, min(duration, 1 / 30))) + } + } + } + #if os(iOS) + .onChange(of: isSimulationPaused) { isPaused in + if isPaused { + feedbackGenerator = nil + } else { + feedbackGenerator = UIImpactFeedbackGenerator(style: .soft) + feedbackGenerator?.prepare() + } + } + #endif + .onChange(of: impulseCount) { newValue in + withAnimation(nil) { + if displacement > -10 { + velocity = -initialVelocity + velocity = clamp(-2 * initialVelocity, velocity, 2 * initialVelocity) + } else if velocity < 0 { + jumpBuffered = true + } + } + } + } + + private func update(_ step: Double) { + let newValue: Double + var newVelocity: Double + + if spring.response > 0 { + // Slow down time as the view approaches its target height for + // additional hangtime. + // + // TODO: Does this mean a `Spring` is just a bad way to model this? + let speed: Double + + if targetHeight > 32 { + speed = (1 - 0.8 * clamp(0, -displacement / targetHeight, 1.0)) + } else { + speed = 1 + } + + (newValue, newVelocity) = spring.value( + from: displacement, + to: 0, + velocity: velocity, + // Slow down time for a more floaty feeling. + timestep: step * speed + ) + } else { + newValue = 0 + newVelocity = .zero + } + + if displacement < 0 && newValue >= 0 { + #if os(iOS) + feedbackGenerator?.impactOccurred(intensity: clamp(0, newVelocity / 800, 1)) + #endif + + if jumpBuffered { + newVelocity -= initialVelocity + jumpBuffered = false + } + } + + displacement = newValue + velocity = newVelocity + + if abs(newValue) < 0.04, newVelocity < 0.04 { + displacement = 0 + velocity = .zero + } + } +} + +/// A view modifier that offsets the view vertically for negative values and +/// compresses the view for positive values. +/// +/// TODO: Consider merging this with `Boing`. +private struct SquishOffset: GeometryEffect { + // In points along the y axis. + var displacement: CGFloat = 0 + + internal init(displacement: CGFloat = 0) { + self.displacement = displacement + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let area = size.width * size.height + + var t = CGAffineTransform.identity + + if displacement < 0 { + t = t.translatedBy(x: size.width / 2, y: size.height / 2) + t = t.translatedBy(x: 0, y: displacement) + t = t.translatedBy(x: -size.width / 2, y: -size.height / 2) + } + + if displacement > 0 { + let newHeight = rubberClamp(size.height * 0.8, size.height - displacement / 3, size.height * 1) + let newWidth = area / newHeight + + t = t.translatedBy(x: size.width / 2, y: size.height) + t = t.scaledBy(x: newWidth / size.width, y: newHeight / size.height) + t = t.translatedBy(x: -size.width / 2, y: -size.height) + } + + return ProjectionTransform(t) + } +} + +#if os(iOS) && DEBUG +struct JumpSimulation_Previews: PreviewProvider { + @available(iOS 16.0, *) + struct Preview: View { + @State + var emailCount = 0 + + @State + var height: Double = 100 + + var body: some View { + ZStack { + Color.clear + .background { + AsyncImage(url: URL(string: "https://picsum.photos/1200")!, transaction: Transaction(animation: .default)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(1, contentMode: .fill) + .ignoresSafeArea() + case .failure(let error): + Text(error.localizedDescription) + .font(.caption) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + } + + VStack { + VStack { + Stepper("^[\(emailCount) Email](inflect: true)", value: $emailCount, in: 0...999) + + Slider(value: $height, in: 10 ... 500) + } + .monospacedDigit() + .padding(12) + .background(.white, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 8, y: 4) + + Spacer() + + HStack(spacing: 29) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.green.gradient) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "phone.fill") + .font(.system(size: 38)) + .foregroundStyle(.white) + } + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(LinearGradient(colors: [.blue, .cyan], startPoint: .top, endPoint: .bottom)) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "envelope.fill") + .font(.system(size: 36)) + .foregroundStyle(.white) + } + .overlay(alignment: .topTrailing) { + Text(emailCount.formatted()) + .font(.body) + .fontWeight(.semibold) + .monospacedDigit() + .foregroundColor(.white) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(.red, in: Capsule(style: .continuous)) + .alignmentGuide(.top) { dimensions in + dimensions[VerticalAlignment.center] - 5 + } + .alignmentGuide(.trailing) { dimensions in + dimensions[HorizontalAlignment.center] + 5 + } + .scaleEffect( + x: emailCount > 0 ? 1 : 0, + y: emailCount > 0 ? 1 : 0 + ) + .animation(.spring(response: 0.2), value: emailCount > 0) + } + .changeEffect(.jump(height: height), value: emailCount) + .overlay(alignment: .top) { + Color.red.frame(height: 3).offset(y: -height) + } + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.orange.gradient) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "book.fill") + .font(.system(size: 34)) + .foregroundStyle(.white) + } + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.red.gradient) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "music.quarternote.3") + .font(.system(size: 34)) + .foregroundStyle(.white) + } + } + .fontWeight(.thin) + .padding(16) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 32, style: .continuous)) + } + .padding() + .ignoresSafeArea(edges: .bottom) + } + } + } + + static var previews: some View { + NavigationView { + if #available(iOS 16.0, *) { + Preview() + } + } + } +} + +struct RepeatingJump: PreviewProvider { + @available(iOS 16.0, *) + struct Preview: View { + @State + private var isEnabled: Bool = false + + @State + private var cadence: TimeInterval = 5 + + var body: some View { + VStack { + GroupBox("Jump") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + LabeledContent { + Slider(value: $cadence, in: -1 ... 6) + } label: { + Text("Cadence") + } + } + } + + Spacer() + + let button = Button { + + } label: { + Label("Upwards!", systemImage: "arrow.up") + } + .tint(.green) + .buttonStyle(.borderedProminent) + .controlSize(.large) + + HStack { + button + .conditionalEffect(.repeat(.jump(height: 100), every: cadence), condition: isEnabled) + + button + .conditionalEffect(.repeat(.jump(height: 100).delay(2), every: cadence), condition: isEnabled) + } + + Spacer() + } + .padding() + } + } + + static var previews: some View { + NavigationView { + if #available(iOS 16.0, *) { + Preview() + } + } + } +} + +private extension AnyChangeEffect { + static var overlay: AnyChangeEffect { + .simulation { count in + CountOverlayModifier(impulseCount: count) + } + } + + struct CountOverlayModifier: ViewModifier, Simulative { + var impulseCount: Int = 0 + + var initialVelocity: CGFloat = 0 + + func body(content: Content) -> some View { + content.overlay { + Text(impulseCount.formatted()) + .padding(4) + .background(.blue) + } + } + } +} +#endif diff --git a/Sources/Pow/Effects/PingEffect.swift b/Sources/Pow/Effects/PingEffect.swift new file mode 100644 index 0000000..6e0c27b --- /dev/null +++ b/Sources/Pow/Effects/PingEffect.swift @@ -0,0 +1,14 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that adds one or more shapes that slowly grow and fade-out behind the view. + /// + /// - Parameters: + /// - shape: The shape to use for the effect. + /// - style: The shape style to use for the effect. Defaults to `tint`. + /// - count: The number of shapes to emit. + @available(*, deprecated, renamed: "pulse(shape:style:count:)") + static func ping(shape: some InsettableShape, style: some ShapeStyle = .tint, count: Int) -> AnyChangeEffect { + pulse(shape: shape, style: style, count: count) + } +} diff --git a/Sources/Pow/Effects/PulseEffect.swift b/Sources/Pow/Effects/PulseEffect.swift new file mode 100644 index 0000000..19ee9e8 --- /dev/null +++ b/Sources/Pow/Effects/PulseEffect.swift @@ -0,0 +1,438 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// Specifies how a `pulse` change effect should render its shape style. + enum PulseDrawingMode { + /// Renders the shape style by filling the shape. + case fill + + /// Renders the shape style as a stroke to the shape's path. + case stroke + } + + /// An effect that adds one or more shapes that slowly grow and fade-out behind the view. + /// + /// - Parameters: + /// - shape: The shape to use for the effect. + /// - style: The shape style to use for the effect. Defaults to `tint`. + /// - drawingMode: The mode used to render the shape. Defaults to `fill`. + /// - count: The number of shapes to emit. Defaults to `1`. + /// - layer: The `ParticleLayer` on which to render the effect. Defaults to `local`. + static func pulse(shape: some InsettableShape, style: some ShapeStyle = .tint, drawingMode: PulseDrawingMode = .fill, count: Int = 1, layer: ParticleLayer = .local) -> AnyChangeEffect { + let clampedCount = max(1, count) + let cooldown: Double = Double(clampedCount - 1) * 0.2 + switch drawingMode { + case .stroke: + return .animation({ change in + PulseStrokeModifier(shape: shape, style: style, layer: layer, count: clampedCount, change: change) + }, animation: .linear(duration: 2), cooldown: cooldown) + case .fill: + return .animation({ change in + PulseFillModifier(shape: shape, style: style, layer: layer, count: clampedCount, change: change) + }, animation: .linear(duration: 4), cooldown: cooldown) + } + } +} + +private final class ItemTimer: ObservableObject { + @Published + private(set) var items: [UUID] = [] + + @Published + private(set) var pulsesRemaining: Int = 0 { + didSet { + if oldValue == 0, pulsesRemaining > 0 { + resume() + } + } + } + + private var timer: Timer? { + willSet { + timer?.invalidate() + } + } + + init() {} + + func remove(_ item: UUID) { + items.removeAll { $0 == item } + } + + func queue(pulses: Int) { + pulsesRemaining += pulses + } + + private func resume(interval: TimeInterval = 0.2, delay: TimeInterval = 0) { + if delay != 0 { + timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] t in + self?.resume(interval: interval) + } + } else { + if pulsesRemaining > 0 { + items.append(UUID()) + pulsesRemaining -= 1 + reschedule(interval: interval) + } + } + } + + private func reschedule(interval: TimeInterval = 0.2) { + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] t in + self?.resume(interval: interval) + } + } + + func pause() { + timer = nil + } +} + +private struct PulseStrokeModifier: ViewModifier, Animatable, AnimatableModifier { + var shape: EffectShape + + var style: EffectShapeStyle + + var layer: ParticleLayer + + var count: Int + + var change: Int + + var animatableData = EmptyAnimatableData() + + @StateObject + private var timer: ItemTimer = ItemTimer() + + public func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + let insetAmount = min(proxy.size.width, proxy.size.height) * 2.0 + let lineWidth = max(1, insetAmount / 25) + + ZStack { + ForEach(timer.items, id: \.self) { item in + shape + .fill(.clear) + .transition( + AnyTransition.asymmetric( + insertion: .movingParts.pulseStroke(shape: shape, style: style, lineWidth: lineWidth, layer: layer, insetAmount: insetAmount, count: count) { + timer.remove(item) + }, + removal: .identity + ) + ) + } + } + .compositingGroup() + } + .allowsHitTesting(false) + } + .onChange(of: change) { c in + timer.queue(pulses: max(0, count)) + } + } +} + +private struct PulseFillModifier: ViewModifier, Animatable, AnimatableModifier { + var shape: EffectShape + + var style: EffectShapeStyle + + var layer: ParticleLayer + + var count: Int + + var change: Int + + var animatableData = EmptyAnimatableData() + + @StateObject + private var timer: ItemTimer = ItemTimer() + + public func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + let insetAmount: CGFloat = min(proxy.size.width, proxy.size.height) * 2.0 + + ZStack { + ForEach(timer.items, id: \.self) { item in + shape + .fill(.clear) + .transition( + AnyTransition.asymmetric( + insertion: .movingParts.pulseFill(shape: shape, style: style, layer: layer, insetAmount: insetAmount, count: count) { + timer.remove(item) + }, + removal: .identity + ) + ) + } + } + .compositingGroup() + } + } + .onChange(of: change) { _ in + timer.queue(pulses: max(0, count)) + } + } +} + +private extension AnyTransition.MovingParts { + static func pulseStroke(shape: some InsettableShape, style: some ShapeStyle, lineWidth: CGFloat, layer: ParticleLayer, insetAmount: CGFloat, count: Int, onCompletion: @escaping () -> Void) -> AnyTransition { + .modifier( + active: PulseStrokeAnimationModifier( + animatableData: 0.0, + shape: shape, + style: style, + lineWidth: lineWidth, + layer: layer, + insetAmount: insetAmount + ), + identity: PulseStrokeAnimationModifier( + animatableData: 1.0, + shape: shape, + style: style, + lineWidth: lineWidth, + layer: layer, + insetAmount: insetAmount, + onCompletion: onCompletion + ) + ) + } + + static func pulseFill(shape: some InsettableShape, style: some ShapeStyle, layer: ParticleLayer, insetAmount: CGFloat, count: Int, onCompletion: @escaping () -> Void) -> AnyTransition { + .modifier( + active: PulseFillAnimationModifier( + animatableData: 0.0, + shape: shape, + style: style, + layer: layer, + insetAmount: insetAmount + ), + identity: PulseFillAnimationModifier( + animatableData: 1.0, + shape: shape, + style: style, + layer: layer, + insetAmount: insetAmount, + onCompletion: onCompletion + ) + ) + } +} + +private struct PulseStrokeAnimationModifier: ViewModifier, Animatable, AnimatableModifier { + var animatableData: CGFloat + + var shape: EffectShape + + var style: EffectShapeStyle + + var lineWidth: CGFloat + + var layer: ParticleLayer + + var insetAmount: CGFloat + + var onCompletion: () -> Void = {} + + @Environment(\.colorScheme) + private var colorScheme + + func body(content: Content) -> some View { + content + .particleLayerOverlay(layer: layer) { + let progress = animatableData + let x: CGFloat = progress.beat(intensity: 5.0, frequency: 0.5) + let nx: CGFloat = x - 0.5 + let v: CGFloat = sin(.pi * x) + + shape + .inset(by: nx * -insetAmount) + .strokeBorder(style, lineWidth: lineWidth * v) + .opacity(1.0 - asin(.pi * Double(progress) / 2.0)) + .blur(radius: lineWidth * 0.125 * v) + .brightness(colorScheme == .dark ? Double(v) * 0.75 : 0.0) + } + .animation(nil, value: animatableData) + .onChange(of: animatableData == 1.0) { newValue in + if newValue { + onCompletion() + } + } + } +} + +private struct PulseFillAnimationModifier: ViewModifier, Animatable, AnimatableModifier { + var animatableData: CGFloat + + var shape: EffectShape + + var style: EffectShapeStyle + + var layer: ParticleLayer + + var insetAmount: CGFloat + + var onCompletion: () -> Void = {} + + @Environment(\.colorScheme) + private var colorScheme + + func body(content: Content) -> some View { + + content + .particleLayerBackground(layer: layer) { + let progress = animatableData + let x: CGFloat = progress.beat(intensity: 5.0, frequency: 0.5) + let nx: CGFloat = x - 0.5 + + shape + .inset(by: nx * -insetAmount) + .fill(style) + .opacity(0.33 - asin(.pi * Double(progress) / 2.0)) + } + .animation(nil, value: animatableData) + .onChange(of: animatableData == 1.0) { newValue in + if newValue { + onCompletion() + } + } + } +} + + +private extension CGFloat { + func beat(intensity: CGFloat = 2.0, frequency: CGFloat = 2.0) -> CGFloat { + let v = atan(sin(self * .pi * frequency) * intensity) + return (v + .pi / 2.0) / .pi + } +} + +#if os(iOS) && DEBUG +struct PulseEffect_Previews: PreviewProvider { + struct Preview: View { + @State + private var pingCount = 0 + + @State + private var pulseCount = 0 + + @State + private var isPressingPulse = false + + @State + private var hearbeatCount = 0 + + @State + private var isPressingHeartbeat = false + + var body: some View { + VStack(spacing: 8) { + Spacer() + + Label("Ping (New)", systemImage: "antenna.radiowaves.left.and.right") + .foregroundColor(.white) + .padding() + .background(.green, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .changeEffect(.pulse(shape: RoundedRectangle(cornerRadius: 16, style: .continuous), count: 3), value: pingCount) + .tint(.green) + .onTapGesture { + pingCount += 1 + } + + Spacer() + + VStack(spacing: 32) { + let scale = isPressingPulse ? 0.95 : 1.0 + Label("Pulse", systemImage: "waveform.path.ecg") + .foregroundStyle(.white) + .padding() + .background(.mint, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .brightness(isPressingPulse ? -0.15 : 0) + .scaleEffect(x: scale, y: scale) + .animation(.spring(response: isPressingPulse ? 0.1 : 0.4, dampingFraction: isPressingPulse ? 1 : 0.5), value: isPressingPulse) + .changeEffect(.shine(duration: 0.5), value: pulseCount) + .changeEffect(.pulse(shape: RoundedRectangle(cornerRadius: 16, style: .continuous), drawingMode: .stroke, count: 3, layer: .named("root")).delay(0.1), value: pulseCount) + .tint(.mint) + .font(.system(.title, design: .rounded).bold()) + } + ._onButtonGesture(pressing: { pressing in + isPressingPulse = pressing + }, perform: { + pulseCount += 1 + }) + .padding() + .clipped() + + Spacer() + + Group { + VStack(spacing: 32) { + let scale = isPressingHeartbeat ? 0.95 : 1.0 + Label("Update", systemImage: "heart.circle") + .foregroundStyle(.red, .red.opacity(0.5)) + .brightness(isPressingHeartbeat ? -0.15 : 0) + .scaleEffect(x: scale, y: scale) + .animation(.spring(response: isPressingHeartbeat ? 0.1 : 0.4, dampingFraction: isPressingHeartbeat ? 1 : 0.5), value: isPressingHeartbeat) + .changeEffect(.shine(duration: 0.5), value: hearbeatCount) + .changeEffect(.pulse(shape: Circle().inset(by: 6.5), style: .red, drawingMode: .stroke, count: 50).delay(0.1), value: hearbeatCount) + .font(.system(size: 72, design: .rounded)) + } + ._onButtonGesture(pressing: { pressing in + isPressingHeartbeat = pressing + }, perform: { + hearbeatCount += 1 + }) + .labelStyle(.iconOnly) + + VStack(spacing: 32) { + let scale = isPressingHeartbeat ? 0.95 : 1.0 + Label("Update", systemImage: "heart.circle") + .foregroundStyle(.red, .red.opacity(0.5)) + .brightness(isPressingHeartbeat ? -0.15 : 0) + .scaleEffect(x: scale, y: scale) + .animation(.spring(response: isPressingHeartbeat ? 0.1 : 0.4, dampingFraction: isPressingHeartbeat ? 1 : 0.5), value: isPressingHeartbeat) + .changeEffect(.shine(duration: 0.5), value: hearbeatCount) + .changeEffect(.pulse(shape: Circle().inset(by: 6.5), style: .red, drawingMode: .stroke).delay(0.1), value: hearbeatCount) + } + ._onButtonGesture(pressing: { pressing in + isPressingHeartbeat = pressing + }, perform: { + hearbeatCount += 1 + }) + .labelStyle(.iconOnly) + } + + Spacer() + + VStack { + Stepper(value: $pingCount) { + Text("Pings ") + Text("(\(pingCount.formatted()))").foregroundColor(.secondary) + } + Stepper(value: $pulseCount) { + Text("Pulses ") + Text("(\(pulseCount.formatted()))").foregroundColor(.secondary) + } + Stepper(value: $hearbeatCount) { + Text("Heartbeats ") + Text("(\(hearbeatCount.formatted()))").foregroundColor(.secondary) + } + } + } + .padding() + .particleLayer(name: "root") + } + } + + static var previews: some View { + Preview() + .preferredColorScheme(.dark) + .previewDisplayName("Dark Color Scheme") + Preview() + .preferredColorScheme(.light) + .previewDisplayName("Light Color Scheme") + } +} +#endif diff --git a/Sources/Pow/Effects/PushDownEffect.swift b/Sources/Pow/Effects/PushDownEffect.swift new file mode 100644 index 0000000..c671547 --- /dev/null +++ b/Sources/Pow/Effects/PushDownEffect.swift @@ -0,0 +1,32 @@ +import SwiftUI + +public extension AnyConditionalEffect { + /// An effect that pushes down the view while a condition is true. + static var pushDown: AnyConditionalEffect { + .continuous( + .modifier { isActive in + PressDownEffectModifier(isActive: isActive) + } + ) + } +} + +// Copy of BounceButtonHighlightModifier +struct PressDownEffectModifier: ViewModifier, Continuous { + var isActive: Bool + + func body(content: Content) -> some View { + let animation: Animation = { + if isActive { + return .interactiveSpring(response: 0.20, dampingFraction: 0.4) + } else { + return .interactiveSpring(response: 0.30, dampingFraction: 0.4, blendDuration: 0.6) + } + }() + + let d = isActive ? 0.95 : 1 + + content + .modifier(_ScaleEffect(scale: CGSize(width: d, height: d)).animation(animation)) + } +} diff --git a/Sources/Pow/Effects/RisingParticleEffect.swift b/Sources/Pow/Effects/RisingParticleEffect.swift new file mode 100644 index 0000000..b940a36 --- /dev/null +++ b/Sources/Pow/Effects/RisingParticleEffect.swift @@ -0,0 +1,314 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that emits the provided particles from the origin point and slowly float up while moving side to side. + /// + /// This effect respects `particleLayer()`. + /// + /// - Parameters: + /// - origin: The origin of the particle. + /// - layer: The `ParticleLayer` on which to render the effect, default is `local`. + /// - particles: The particles to emit. + static func rise(origin: UnitPoint = .center, layer: ParticleLayer = .local, @ViewBuilder _ particles: () -> some View) -> AnyChangeEffect { + let particles = particles() + return .simulation { change in + RisingParticleSimulation(origin: origin, particles: particles, impulseCount: change, layer: layer) + } + } + + /// An effect that emits the provided particle from the origin point and slowly float up while moving side to side. + /// + /// - Parameters: + /// - origin: The origin of the particle. + /// - particle: The particle to emit. + @available(*, deprecated, renamed: "rise(origin:_:)") + static func risingParticle(origin: UnitPoint = .center, @ViewBuilder _ particle: () -> some View) -> AnyChangeEffect { + rise(origin: origin, particle) + } +} + +internal struct RisingParticleSimulation: ViewModifier, Simulative { + var origin: UnitPoint + + var particles: ParticlesView + + var impulseCount: Int = 0 + + var initialVelocity: CGFloat = 0.0 + + private let spring = Spring(zeta: 1, stiffness: 30) + + private struct Item: Identifiable { + let id: UUID + var progress: CGFloat + var velocity: CGFloat + var change: Int + } + + @State + private var items: [Item] = [] + + private let target: CGFloat = 1.0 + + private let layer: ParticleLayer + + private var isSimulationPaused: Bool { + items.isEmpty + } + + internal init(origin: UnitPoint, particles: ParticlesView, impulseCount: Int = 0, layer: ParticleLayer) { + self.origin = origin + self.particles = particles + self.impulseCount = impulseCount + self.layer = layer + } + + private struct _ViewContainer: SwiftUI._VariadicView.MultiViewRoot { + func body(children: _VariadicView.Children) -> some View { + ForEach(Array(zip(0..., children)), id: \.1.id) { offset, child in + child.tag(offset) + } + } + } + + func body(content: Content) -> some View { + let overlay = TimelineView(.animation(paused: isSimulationPaused)) { context in + let insets = EdgeInsets(top: 80, leading: 40, bottom: 20, trailing: 40) + + Canvas { context, size in + var symbols: [GraphicsContext.ResolvedSymbol] = [] + + var i = 0 + var nextSymbol: GraphicsContext.ResolvedSymbol? = context.resolveSymbol(id: i) + while let symbol = nextSymbol { + symbols.append(symbol) + i += 1 + nextSymbol = context.resolveSymbol(id: i) + } + + if symbols.isEmpty { return } + + context.translateBy(x: size.width / 2, y: insets.top + (size.height - insets.top - insets.bottom) / 2) + + for item in items { + var rng = SeededRandomNumberGenerator(seed: item.id) + + let symbolIndex = max(0, item.change - 1) % symbols.count + + let progress = item.progress + + let angle = Angle.degrees(.random(in: -10 ... 10, using: &rng)) + + let scale = 1 + 0.2 * progress + + context.opacity = 1.0 - pow(1.0 - 2.0 * progress, 4.0) + context.drawLayer { context in + context.rotate(by: .degrees(-angle.degrees * Double(1 - progress))) + context.translateBy( + x: progress * sin(progress * 1.4 * .pi) * .random(in: -20 ... 20, using: &rng), + y: progress * -50 - .random(in: 0 ... 10, using: &rng) + ) + context.rotate(by: angle) + context.scaleBy(x: scale, y: scale) + + let symbol = symbols[symbolIndex] + + context.draw(symbol, at: .zero) + } + } + } symbols: { + SwiftUI._VariadicView.Tree(_ViewContainer()) { + particles + } + } + .padding(insets.inverse) + .modifier(RelativeOffsetModifier(anchor: origin)) + .allowsHitTesting(false) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(max(0, min(duration, 1 / 30))) + } + } + } + + content + .particleLayerOverlay(alignment: .top, layer: layer, isEnabled: !isSimulationPaused) { + overlay + } + .onChange(of: impulseCount) { newValue in + let item = Item( + id: UUID(), + progress: 0, + velocity: initialVelocity, + change: newValue + ) + withAnimation(nil) { + items.append(item) + } + } + } + + private func update(_ step: Double) { + for index in items.indices.reversed() { + var item = items[index] + + if spring.response > 0 { + let (newValue, newVelocity) = spring.value( + from: item.progress, + to: target, + velocity: item.velocity, + timestep: step + ) + item.progress = newValue + item.velocity = newVelocity + } else { + item.progress = target + item.velocity = .zero + } + + items[index] = item + + if abs(item.progress - target) < 0.04 && item.velocity < 0.04 { + items.remove(at: index) + } + } + } +} + +private struct RelativeOffsetModifier: GeometryEffect { + var anchor: UnitPoint + + func effectValue(size: CGSize) -> ProjectionTransform { + let x = size.width * (-0.5 + anchor.x) + let y = size.height * (-0.5 + anchor.y) + + return ProjectionTransform( + CGAffineTransform(translationX: x, y: y) + ) + } +} + +#if os(iOS) && DEBUG +struct RisingParticleEffect_Previews: PreviewProvider { + struct ButtonPreview: View { + @State + var claps = 28 + + @State + var stars = 18 + + @State + var likes = 61 + + var body: some View { + HStack { + Button { + claps += 1 + } label: { + HStack { + Image(systemName: "hands.clap.fill") + Text(claps.formatted()) + } + } + .changeEffect(.rise(origin: UnitPoint(x: 0.7, y: 0.5)) { + Group { + Text("+1") + Image(systemName: "hands.clap") + Image(systemName: "sparkle") + Image(systemName: "hand.thumbsup") + } + .font(.caption.bold()) + .foregroundStyle(.tint) + .tint(.blue) + }, value: claps) + + Button { + stars += 1 + } label: { + HStack { + Image(systemName: "star.fill") + Text("\(stars, format: .number)") + } + } + .changeEffect(.rise(origin: UnitPoint(x: 0.7, y: 0.5)) { + Text("\(1, format: .number.sign(strategy: .always()))") + .font(.caption) + .bold() + .foregroundStyle(.tint) + }, value: stars) + .tint(.yellow) + .environment(\.layoutDirection, .rightToLeft) + .environment(\.locale, .init(identifier: "ar_EG")) + + Button { + likes += 1 + } label: { + HStack { + Image(systemName: "heart.fill") + Text(likes.formatted()) + } + } + .changeEffect(.rise(origin: UnitPoint(x: 0.3, y: 0.5)) { + Image(systemName: "heart.fill") + .foregroundStyle(.tint) + }, value: likes) + .clipped() + .tint(.red) + } + .particleLayer(name: "root") + .buttonStyle(.bordered) + .monospacedDigit() + .padding() + } + } + + struct ListPreview: View { + @State + var claps: [Int: Int] = [:] + + var body: some View { + NavigationView { + List { + ForEach(0 ..< 30) { i in + HStack { + Text("Cell #\(i)") + Spacer() + + Button { + claps[i, default: 0] += 1 + } label: { + Label(claps[i, default: 0].formatted(), systemImage: "heart.fill") + } + .monospacedDigit() + .controlSize(.small) + .buttonBorderShape(.capsule) + .changeEffect(.rise(layer: .named("root")) { + Image(systemName: "heart.fill").foregroundStyle(.tint) + }, value: claps[i, default: 0]) + .tint(.red) + } + } + } + .labelStyle(.titleOnly) + .buttonStyle(.borderedProminent) + .navigationTitle("Cells") + } + .particleLayer(name: "root") + } + } + + static var previews: some View { + NavigationView { + ButtonPreview() + } + .environment(\.colorScheme, .dark) + .previewDisplayName("Buttons") + + + ListPreview() + .environment(\.colorScheme, .dark) + .previewDisplayName("Escaping List") + } +} +#endif diff --git a/Sources/Pow/Effects/ShakeEffect.swift b/Sources/Pow/Effects/ShakeEffect.swift new file mode 100644 index 0000000..59cb4ab --- /dev/null +++ b/Sources/Pow/Effects/ShakeEffect.swift @@ -0,0 +1,223 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that shakes the view when a change happens. + static var shake: AnyChangeEffect { + .simulation { change in + ShakeSimulationModifier(impulseCount: change, phaseLength: ShakeRate.default.phaseLength) + } + } + + /// The rate of the shake effect. + enum ShakeRate { + case `default` + case fast + + fileprivate var phaseLength: CGFloat { + switch self { + case .default: return 0.8 + case .fast: return 0.3 + } + } + } + + /// An effect that shakes the view when a change happens. + /// + /// - Parameter rate: The rate of the shake. + static func shake(rate: ShakeRate) -> AnyChangeEffect { + .simulation { change in + ShakeSimulationModifier(impulseCount: change, phaseLength: rate.phaseLength) + } + } +} + +internal struct ShakeSimulationModifier: ViewModifier, Simulative { + // TODO: Not used, remove from protocol + var initialVelocity: CGFloat = 0 + + var impulseCount: Int + + var phaseLength: CGFloat + + @State + private var shakeCount: CGFloat = 0 + + @State + private var displacement: CGFloat = 0 + + @State + private var integrator: SecondOrderDynamics = SecondOrderDynamics( + f: 3, + zeta: 0.85, + r: -0.2 + ) + + fileprivate var target: CGFloat { + 16 * sin(2 * .pi * shakeCount) + } + + private var isSimulationPaused: Bool { + displacement == .zero && shakeCount <= 0 + } + + public func body(content: Content) -> some View { + let t = Transform3DEffect( + translation: (displacement, 0, -20), + angle: .degrees(displacement / 2), + axis: (0, 1, 0), + anchorZ: -20, + perspective: 1 / 6 + ) + + TimelineView(.animation(paused: isSimulationPaused)) { context in + content + .modifier(t) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(max(0, min(duration, 1 / 30))) + } + } + } + .onChange(of: impulseCount) { newValue in + withAnimation(nil) { + shakeCount += 2 + + if shakeCount > 3 { + shakeCount = 2 + fmod(shakeCount, 1) + } + } + } + } + + private func update(_ step: Double) { + displacement = integrator.update(target: target, timestep: step) + + if !displacement.isNormal { + displacement = 0 + } + + shakeCount = clamp(0, shakeCount - 2 * (step / phaseLength), .infinity) + } +} + +#if os(iOS) && DEBUG +struct ShakeSimulation_Previews: PreviewProvider { + @available(iOS 16.0, *) + struct Preview: View { + @State + var emailCount = 0 + + var body: some View { + ZStack { + Color.clear + .background { + AsyncImage(url: URL(string: "https://picsum.photos/1200")!, transaction: Transaction(animation: .default)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(1, contentMode: .fill) + .ignoresSafeArea() + case .failure(let error): + Text(error.localizedDescription) + .font(.caption) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + } + + VStack { + Stepper("^[\(emailCount) Email](inflect: true)", value: $emailCount, in: 0...999) + .monospacedDigit() + .padding(12) + .background(.white, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 8, y: 4) + + Spacer() + + HStack(spacing: 29) { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.green.gradient) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "phone.fill") + .font(.system(size: 38)) + .foregroundStyle(.white) + } + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(LinearGradient(colors: [.blue, .cyan], startPoint: .top, endPoint: .bottom)) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "envelope.fill") + .font(.system(size: 36)) + .foregroundStyle(.white) + } + .overlay(alignment: .topTrailing) { + Text(emailCount.formatted()) + .font(.body) + .fontWeight(.semibold) + .monospacedDigit() + .foregroundColor(.white) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(.red, in: Capsule(style: .continuous)) + .alignmentGuide(.top) { dimensions in + dimensions[VerticalAlignment.center] - 5 + } + .alignmentGuide(.trailing) { dimensions in + dimensions[HorizontalAlignment.center] + 5 + } + .scaleEffect( + x: emailCount > 0 ? 1 : 0, + y: emailCount > 0 ? 1 : 0 + ) + .animation(.spring(response: 0.2), value: emailCount > 0) + } + .changeEffect(.shake, value: emailCount) + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.orange.gradient) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "book.fill") + .font(.system(size: 34)) + .foregroundStyle(.white) + } + + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.red.gradient) + .saturation(1.5) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: "music.quarternote.3") + .font(.system(size: 34)) + .foregroundStyle(.white) + } + } + .fontWeight(.thin) + .padding(16) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 32, style: .continuous)) + } + .padding() + .ignoresSafeArea(edges: .bottom) + } + } + } + + static var previews: some View { + NavigationView { + if #available(iOS 16.0, *) { + Preview() + } + } + } +} +#endif diff --git a/Sources/Pow/Effects/ShineEffect.swift b/Sources/Pow/Effects/ShineEffect.swift new file mode 100644 index 0000000..70d7b6e --- /dev/null +++ b/Sources/Pow/Effects/ShineEffect.swift @@ -0,0 +1,156 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that highlights the view with a shine moving over the view. + /// + /// The shine moves from the top leading edge to bottom trailing edge. + static var shine: AnyChangeEffect { + shine(duration: 1) + } + + /// An effect that highlights the view with a shine moving over the view. + /// + /// The shine moves from the top leading edge to bottom trailing edge. + static func shine(duration: Double) -> AnyChangeEffect { + .animation({ change in + ShineModifier(angle: nil, animatableData: CGFloat(change)) + }, animation: .easeInOut(duration: duration), cooldown: duration * 0.5) + } + + /// An effect that highlights the view with a shine moving over the view. + /// + /// The angle is relative to the current `layoutDirection`, such that 0° represents sweeping towards the trailing edge and 90° represents sweeping towards the bottom edge. + /// + /// - Parameters: + /// - angle: The angle of the animation. + /// - duration: The duration of the animation. + static func shine(angle: Angle, duration: Double = 1.0) -> AnyChangeEffect { + .animation({ change in + ShineModifier(angle: angle, animatableData: CGFloat(change)) + }, animation: .easeInOut(duration: duration), cooldown: duration * 0.5) + } +} + +internal struct ShineModifier: ViewModifier, Animatable { + var angle: Angle? + + public var animatableData: CGFloat = 0 + public func body(content: Content) -> some View { + let fraction = CGFloat(fmodf(Float(animatableData), 1)) + + content + .overlay( + GeometryReader { proxy in + let base = sin(Double(fraction)) + + let frame = CGRect(origin: .zero, size: proxy.size) + + let resolvedAngle = angle ?? frame.topLeft.angle(to: frame.bottomRight) + + let bounds = frame.boundingBox(at: resolvedAngle) + + LinearGradient( + colors: stride(from: 0.0, through: .pi, by: 0.2).map { + .white.opacity(pow(sin($0), 2) * 0.8 * base) + }, + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: bounds.width * 2, height: bounds.height) + .position( + x: (bounds.minX - bounds.width / 2) + (fraction * bounds.width * 2), + y: bounds.midY + ) + .rotationEffect(resolvedAngle) + .blendMode(.sourceAtop) + .opacity(1.0 - pow(fraction, 8.0)) + } + .allowsHitTesting(false) + ) + .compositingGroup() + .animation(nil, value: fraction) + } +} + +#if os(iOS) && DEBUG +struct ShineChangeEffect_Previews: PreviewProvider { + struct Cart: View { + @State + var itemCount: Int = 0 + + @State private var degrees: Double = 45 + + var body: some View { + List { + HStack(alignment: .center, spacing: 16) { + AsyncImage(url: URL(string: "https://movingparts.io/frontpage/checkout-smooth-blend@3x.png")) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + } + } + .background(Color(white: 0.9)) + .frame(width: 72, height: 72) + .changeEffect(.shine(angle: .degrees(180), duration: 0.5), value: itemCount, isEnabled: itemCount > 0) + + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 8) { + Text("Seasonal Blend, Spring Here") + .font(.body.weight(.medium)) + .lineSpacing(-10) + + Text("500g") + .font(.callout) + .foregroundColor(.secondary) + } + Spacer() + + VStack(alignment: .trailing) { + Text("\(itemCount.formatted())× ").foregroundColor(.secondary) + + Text(9.99.formatted(.currency(code: "EUR"))) + Stepper(value: $itemCount, in: 0...10) { + Text("Quantity ") + Text(itemCount.formatted()).foregroundColor(.secondary) + } + .labelsHidden() + .font(.callout) + } + .font(.callout) + } + } + + Text(degrees, format: .number.precision(.fractionLength(2))) + Slider(value: $degrees, in: -360.0...360.0) + + } + .listStyle(.plain) + .navigationTitle("Cart") + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 32) { + Button { + } label: { + Label("Checkout", systemImage: "cart") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(itemCount == 0) + .animation(.default, value: itemCount == 0) + .changeEffect( + .shine(angle: .degrees(degrees)).delay(0.5), + value: itemCount, + isEnabled: itemCount > 0 + ) + .padding() + } + } + } + } + + static var previews: some View { + NavigationView { + Cart() + } + } +} +#endif diff --git a/Sources/Pow/Effects/SmokeEffect.swift b/Sources/Pow/Effects/SmokeEffect.swift new file mode 100644 index 0000000..feb00a6 --- /dev/null +++ b/Sources/Pow/Effects/SmokeEffect.swift @@ -0,0 +1,365 @@ +import SwiftUI +import simd + +public extension AnyConditionalEffect { + /// An effect that emits smoke from the view. + static var smoke: AnyConditionalEffect { + .smoke(layer: .local) + } + + /// An effect that emits smoke from the view. + /// + /// - Parameter layer: The `ParticleLayer` on which to render the effect, default is `local`. + static func smoke(layer: ParticleLayer) -> AnyConditionalEffect { + .continuous( + .modifier { isActive in + SmokeEffect(isActive: isActive, layer: layer) + } + ) + } +} + +private struct SmokeEffect: ViewModifier, Continuous { + var isActive: Bool + + let layer: ParticleLayer + + let particles = [ + "anvil_smoke_gray", + "anvil_smoke_gray_blur", + "anvil_smoke_gray_alt", + ] + + func body(content: Content) -> some View { + content + .background { + smoke + .mask(alignment: .trailing) { + Rectangle() + } + .allowsHitTesting(false) + } + .particleLayerBackground(layer: layer) { + smoke + .overlay { + Rectangle() + .blendMode(.destinationOut) + } + .compositingGroup() + .allowsHitTesting(false) + } + } + + private var smoke: some View { + GeometryReader { proxy in + ZStack { + ForEach(Array(particles.enumerated()), id: \.element) { (offset, particle) in + #if os(iOS) + let image = UIImage(named: particle, in: .module, with: nil)!.cgImage! + #elseif os(macOS) + let image = Bundle.module.image(forResource: particle)!.cgImage(forProposedRect: nil, context: nil, hints: nil)! + #endif + + SmokeLayerView(size: proxy.size, isActive: isActive, particle: image, seed: UInt32(offset)) + } + } + } + } +} + +#if os(iOS) +private class EmitterView: UIView { + override class var layerClass : AnyClass { + return CAEmitterLayer.self + } + + var emitterLayer: CAEmitterLayer { + layer as! CAEmitterLayer + } +} +#endif + +#if os(macOS) +private class EmitterView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.wantsLayer = true + layer?.masksToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func makeBackingLayer() -> CALayer { + CAEmitterLayer() + } + + var emitterLayer: CAEmitterLayer { + layer as! CAEmitterLayer + } + + override var isFlipped: Bool { + return true + } +} +#endif + +private struct SmokeLayerView: ViewRepresentable { + var size: CGSize + + var isActive: Bool + + var particle: CGImage + + var seed: UInt32 + + func makeView(context: Context) -> EmitterView { + let view = EmitterView() + + let emitterLayer = view.emitterLayer + emitterLayer.seed = seed + + let particleScale = size.width / 750.0 + let particleWidth: CGFloat = 256 + let inset: CGFloat = particleWidth * particleScale / 2.25 + + do { + emitterLayer.emitterPosition = CGRect(origin: .zero, size: size) + .divided(atDistance: 100, from: .minYEdge) + .slice + .center + emitterLayer.emitterSize = CGRect(origin: .zero, size: size) + .divided(atDistance: 100, from: .minYEdge) + .slice + .insetBy(dx: inset, dy: inset) + .size + emitterLayer.emitterShape = .rectangle + + let cell = CAEmitterCell() + cell.birthRate = max(10.0, Float(size.width / 5.0)) + cell.lifetime = 1.5 + cell.velocity = min(175, size.width * 0.75) + cell.velocityRange = 10 + + cell.spinRange = .pi + + cell.alphaRange = 1.0 + cell.alphaSpeed = -1.0 + + cell.scale = size.width / 750.0 + cell.scaleRange = size.width / 1000.0 + cell.scaleSpeed = size.width / -2000.0 + + cell.emissionRange = .pi * 0.1 + cell.emissionLongitude = .pi * -0.5 + + cell.contents = particle + + emitterLayer.emitterCells = [cell] + + emitterLayer.lifetime = isActive ? 1 : 0 + } + + return view + } + + func updateView(_ view: EmitterView, context: Context) { + view.emitterLayer.lifetime = isActive ? 1 : 0 + } +} + +#if DEBUG +struct ContinuousParticleEffect_Previews: PreviewProvider { + private struct Preview: View { + @State + private var isEnabled: Bool = true + + var body: some View { + GroupBox("Smoke") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + Button { + + } label: { + Label("Burn", systemImage: "opticaldisc.fill") + .foregroundColor(.orange) + .font(.title3) + } +// .buttonBorderShape(.capsule) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.smoke, condition: isEnabled) + .tint(.init(white: 0.3)) + } + } + .padding() + } + } + + private struct PreviewS: View { + @State + private var isEnabled: Bool = true + + var body: some View { + GroupBox("Smoke") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + Button { + + } label: { + Label("Burn", systemImage: "opticaldisc.fill") + .foregroundColor(.orange) + .font(.caption) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .conditionalEffect(.smoke, condition: isEnabled) + .tint(.init(white: 0.3)) + } + } + .padding() + } + } + + private struct Preview2: View { + @State + private var isEnabled: Bool = true + + var body: some View { + GroupBox("Smoke") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + Button { + + } label: { + Label("Burn", systemImage: "opticaldisc.fill") + .foregroundColor(.orange) + .font(.largeTitle) + .padding(.horizontal, 80) + .padding(.vertical, 200) + } + .buttonStyle(.borderedProminent) +// .buttonBorderShape(.roundedRectangle(radius: 70)) + .controlSize(.large) + .conditionalEffect(.smoke, condition: isEnabled) + .tint(.init(white: 0.3)) + } + } + .padding() + } + } + + private struct Preview3: View { + @State + private var isEnabled: Bool = true + + var body: some View { + NavigationView { + ScrollView { + GroupBox("Smoke") { + VStack { + Button { + + } label: { + Label("Burn", systemImage: "opticaldisc.fill") + .foregroundColor(.orange) + .font(.largeTitle) + .padding() + .padding() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.smoke(layer: .named("root")), condition: isEnabled) + .tint(.init(white: 0.3)) + + Toggle("Enabled", isOn: $isEnabled) + } + } + .clipped() + .padding() + } + .navigationTitle("Smoke") + } + .particleLayer(name: "root") + } + } + + #if os(iOS) + private struct PreviewLayer: View { + @State + private var isEnabled: Bool = true + + var body: some View { + VStack { + GeometryReader { proxy in + SmokeLayerView(size: proxy.size, isActive: isEnabled, particle: UIImage(named: "anvil_smoke_gray", in: .module, with: nil)!.cgImage!, seed: 0) + } + .frame(width: 200, height: 100) + .border(.red) + + Toggle("Enabled", isOn: $isEnabled) + } + .padding() + } + } + #endif + + private struct PreviewAlt: View { + @State + private var isEnabled: Bool = true + + var body: some View { + GroupBox("Smoke") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + Button { + + } label: { + Label("Burn", systemImage: "opticaldisc.fill") + .foregroundColor(.orange) + .font(.title3) + } +// .buttonBorderShape(.capsule) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.smoke, condition: isEnabled) + .tint(.init(white: 0.3)) + } + } + .padding() + } + } + + static var previews: some View { + Preview() + .preferredColorScheme(.dark) + .previewDisplayName("Dark") + Preview() + .preferredColorScheme(.light) + .previewDisplayName("Light") + PreviewS() + .preferredColorScheme(.dark) + .previewDisplayName("Small") + Preview2() + .preferredColorScheme(.dark) + .previewDisplayName("Large") + Preview3() + .preferredColorScheme(.dark) + .previewDisplayName("Particle Layer") + + #if os(iOS) + PreviewLayer() + .previewDisplayName("Emitter Layer") + #endif + + PreviewAlt() + .preferredColorScheme(.dark) + .previewDisplayName("Emitter Dark") + } +} +#endif diff --git a/Sources/Pow/Effects/SoundEffect.swift b/Sources/Pow/Effects/SoundEffect.swift new file mode 100644 index 0000000..9821ed3 --- /dev/null +++ b/Sources/Pow/Effects/SoundEffect.swift @@ -0,0 +1,479 @@ +#if os(iOS) +import SwiftUI +import CoreHaptics +import UniformTypeIdentifiers +import AVFoundation + +public extension AnyChangeEffect { + /// Triggers sound effect as feedback whenever a value changes. + /// + /// - Parameter soundEffect: The sound effect to trigger. + static func feedback(_ soundEffect: SoundEffect) -> AnyChangeEffect { + .simulation { change in + SoundEffectModifier(audio: soundEffect, impulseCount: change) + } + } +} + +public struct SoundEffect: Hashable, Sendable { + /// The audio session used to play sound effects or `nil` to get the default audio behavior. + /// + /// Provide an `AVAudioSession` instance if your app is already using an audio session and you want the audio behavior of the sound effects to match other audio in your app. + /// + /// - Note: Sound effects have a different audio behavior when running in a simulator. If you don't set an audio session, Pow activates the shared audio session when needed. + /// If you only want to change the audio behavior for the simulator you can check the target environment before setting the session. + /// ``` + /// #if targetEnvironment(simulator) + /// SoundEffect.audioSession = AVAudioSession.sharedInstance() + /// #endif + /// ``` + static var audioSession: AVAudioSession? + + var urls: [URL] + + var url: URL? { + return urls.first + } + + var volume: Double = 1.0 + + /// Creates a sound effect. + /// + /// If more than one name is given the sound effect will rotate between playing one of those sounds. + /// + /// So for example `SoundEffect("Pop1", "Pop2", "Pop3")` will play a different pop sound every time. + /// + /// - Parameters: + /// - names: One or more names of the sound resource to lookup. + /// - type: The type of the sound resource to lookup. Defaults to `.audio`. + /// - bundle: The bundle to search for the sound resource. Defaults to the main `Bundle`. + public init(_ names: String..., type: UTType = .audio, bundle: Bundle = .main) { + let types: [UTType] + if type == .audio { + types = [type, .aiff, .wav, UTType(filenameExtension: "caf")!, .mpeg4Audio, UTType(filenameExtension: "m4a")!] + } else { + types = [type] + } + + self.urls = [] + + for type in types { + let fileExtensions = type.tags[.filenameExtension] ?? [] + for fileExtension in fileExtensions { + for name in names { + if let url = bundle.url(forResource: name, withExtension: fileExtension) { + self.urls.append(url) + } + } + if urls.count > 0 { + return + } + } + } + + print("No sound resource named \(names.map({ "'\($0)'" }).formatted(.list(type: .and))) with type '\(type)' found in bundle \(bundle)") + } + + /// Create a sound effect from the specified URL. + /// + /// - Parameter url: The URL of the sound to play. + public init(url: URL) { + self.urls = [url] + } + + /// Sets the volume of this sound. + /// + /// - Parameter value: A value between 0.0 (silent) and 1.0 (maximum volume). + public func volume(_ value: Double) -> Self { + var copy = self + copy.volume = value + return copy + } +} + +private struct SoundEffectModifier: ViewModifier, Simulative { + var impulseCount: Int = 0 + + // TODO: Remove from protocol + var initialVelocity: CGFloat = 0 + + var audio: SoundEffect + + init(audio: SoundEffect, impulseCount: Int) { + self.audio = audio + self.impulseCount = impulseCount + } + + let engine: AnySoundEffectPlayer = .shared + + func body(content: Content) -> some View { + content + .onChange(of: impulseCount) { _ in + Task(priority: .userInitiated) { + try await engine.register(audio) + try await engine.play(audio) + try await engine.unregister(audio) + } + } + .onAppear { + Task { + try await engine.register(audio) + } + } + .onChange(of: audio) { [oldValue = audio] newValue in + guard oldValue != newValue else { return } + + Task { + try await engine.unregister(oldValue) + try await engine.register(newValue) + } + } + .onDisappear { + Task { + try await engine.unregister(audio) + } + } + } +} + +private protocol SoundEffectPlayer { + func register(_ audio: SoundEffect) async throws + func unregister(_ audio: SoundEffect) async throws + func play(_ audio: SoundEffect) async throws +} + +private actor AnySoundEffectPlayer: SoundEffectPlayer { + static var shared = AnySoundEffectPlayer() + + let player: any SoundEffectPlayer + + init() { + #if targetEnvironment(simulator) + self.player = AVSoundEffectPlayer() + #else + if CHHapticEngine.capabilitiesForHardware().supportsAudio { + self.player = HapticEngineSoundEffectPlayer() + } else { + self.player = EmptySoundEffectPlayer() + } + #endif + } + + func register(_ audio: SoundEffect) async throws { + try await player.register(audio) + } + + func unregister(_ audio: SoundEffect) async throws { + try await player.unregister(audio) + } + + func play(_ audio: SoundEffect) async throws { + try await player.play(audio) + } +} + +private actor EmptySoundEffectPlayer: SoundEffectPlayer { + func register(_ audio: SoundEffect) async throws { + + } + + func unregister(_ audio: SoundEffect) async throws { + + } + + func play(_ audio: SoundEffect) async throws { + + } +} + +private actor HapticEngineSoundEffectPlayer: SoundEffectPlayer { + private var engine: CHHapticEngine? + + private struct SoundEffectReference { + let resourceID: CHHapticAudioResourceID + + var count: Int = 1 + } + + private var registeredSounds: [URL: SoundEffectReference] = [:] + + private var didSetUp = false + + init() { + + } + + private func setUp() throws { + if didSetUp { return } + defer { didSetUp = true } + + if let audioSession = SoundEffect.audioSession { + engine = try? CHHapticEngine(audioSession: audioSession) + } else { + engine = try? CHHapticEngine() + } + + guard let engine else { return } + + if #available(iOS 16.0, *) { + engine.playsAudioOnly = true + } + + engine.isAutoShutdownEnabled = false + + engine.resetHandler = { + try? engine.start() + } + } + + private func tearDown() async throws { + guard let engine else { return } + + do { + #if DEBUG + print("Stopping engine") + #endif + + try await engine.stop() + } catch { + throw error + } + } + + func register(_ audio: SoundEffect) throws { + try setUp() + + guard let engine else { return } + + for url in audio.urls { + if var newRegisteredSound = registeredSounds[url] { + newRegisteredSound.count += 1 + registeredSounds[url] = newRegisteredSound + } else { + #if DEBUG + print("Registering \(audio)") + #endif + let resourceID = try engine.registerAudioResource(url) + + let reference = SoundEffectReference(resourceID: resourceID) + registeredSounds[url] = reference + } + } + } + + func unregister(_ audio: SoundEffect) async throws { + guard let engine else { return } + + for url in audio.urls { + registeredSounds[url]?.count -= 1 + + if registeredSounds[url]?.count == 0 { + #if DEBUG + print("Unregistering \(audio)") + #endif + + if let resourceID = registeredSounds[url]?.resourceID { + try engine.unregisterAudioResource(resourceID) + } + + registeredSounds[url] = nil + } + } + + if registeredSounds.isEmpty { + try await tearDown() + } + } + + func play(_ audio: SoundEffect) async throws { + guard let engine else { return } + + try await engine.start() + + // TODO: Avoid playing the same sound twice if there are more variations. + guard let url = audio.urls.randomElement() else { return } + + guard let resourceID = registeredSounds[url]?.resourceID else { return } + + try await withCheckedThrowingContinuation { continuation in + let event = CHHapticEvent( + audioResourceID: resourceID, + parameters: [.init(parameterID: .audioVolume, value: Float(audio.volume))], + relativeTime: CHHapticTimeImmediate + ) + + do { + let pattern = try CHHapticPattern(events: [event], parameters: []) + let player = try engine.makeAdvancedPlayer(with: pattern) + + player.completionHandler = { x in + continuation.resume() + } + + try player.start(atTime: CHHapticTimeImmediate) + } catch { + continuation.resume(throwing: error) + } + } + } +} + + +private actor AVSoundEffectPlayer: SoundEffectPlayer { + private struct SoundEffectReference { + let id: UUID + + var count: Int + } + + private var registeredSounds: [SoundEffect: SoundEffectReference] = [:] + + private var shouldDeactivateAudioSession = true + + private var didSetUp = false + + init() { + + } + + private func setUp() throws { + if didSetUp { return } + defer { didSetUp = true } + guard SoundEffect.audioSession == nil else { return } + + let audioSession = AVAudioSession.sharedInstance() + #if targetEnvironment(simulator) + try audioSession.setCategory(.playback, mode: .default) + #else + try audioSession.setCategory(.ambient, mode: .default) + #endif + try audioSession.setActive(true) + + shouldDeactivateAudioSession = true + } + + private func tearDown() throws { + #if DEBUG + print("Stopping engine") + #endif + + guard shouldDeactivateAudioSession else { return } + + let audioSession = AVAudioSession.sharedInstance() + + try audioSession.setActive(false) + } + + func register(_ audio: SoundEffect) { + try? setUp() + + if var updatedSound = registeredSounds[audio] { + updatedSound.count += 1 + registeredSounds[audio] = updatedSound + } else { + #if DEBUG + print("Registering \(audio)") + #endif + let id = UUID() + let sound = SoundEffectReference(id: id, count: 1) + registeredSounds[audio] = sound + } + } + + func unregister(_ audio: SoundEffect) { + guard var registeredSound = registeredSounds[audio] else { return } + + registeredSound.count -= 1 + + if registeredSound.count == 0 { + #if DEBUG + print("Unregistering \(audio)") + #endif + + registeredSounds[audio] = nil + } else { + registeredSounds[audio] = registeredSound + } + + if registeredSounds.count == 0 { + try? tearDown() + } + } + + func play(_ audio: SoundEffect) async throws { + // TODO: Avoid playing the same sound twice if there are more variations. + guard let url = audio.urls.randomElement() else { + return + } + + let player = AVAudioPlayerWithCompletionHandler(url: url, volume: audio.volume) + + try await withCheckedThrowingContinuation { continuation in + player.play { result in + continuation.resume(with: result) + } + } + } +} + +private class AVAudioPlayerWithCompletionHandler: NSObject, AVAudioPlayerDelegate { + let url: URL + + let volume: Double + + var completion: (Result) -> Void + + var player: AVAudioPlayer? + + init(url: URL, volume: Double) { + self.url = url + self.volume = volume + self.completion = { _ in } + self.player = nil + } + + func play(completion: @escaping (Result) -> Void) { + self.completion = completion + + do { + let player = try AVAudioPlayer(contentsOf: url) + player.prepareToPlay() + player.delegate = self + player.volume = Float(volume) + player.play() + self.player = player + } catch { + completion(.failure(error)) + tearDown() + } + } + + func stop() { + player?.stop() + tearDown() + } + + func tearDown() { + player = nil + completion = { _ in } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if flag { + completion(.success(())) + } else { + completion(.failure(AVError(.unknown))) + } + tearDown() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error { + completion(.failure(error)) + } else { + completion(.failure(AVError(.unknown))) + } + tearDown() + } +} +#endif diff --git a/Sources/Pow/Effects/SpinEffect.swift b/Sources/Pow/Effects/SpinEffect.swift new file mode 100644 index 0000000..b53664d --- /dev/null +++ b/Sources/Pow/Effects/SpinEffect.swift @@ -0,0 +1,287 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// The rate of the spin effect. + enum SpinRate { + case `default` + case fast + + fileprivate var maximumVelocity: Angle { + switch self { + case .fast: return .degrees(360 * 4) + case .default: return .degrees(360 * 2) + } + } + + fileprivate var initialVelocity: Angle { + switch self { + case .fast: return .degrees(900) + case .default: return .degrees(360) + } + } + + fileprivate var additionalVelocity: Angle { + switch self { + case .fast: return .degrees(900) + case .default: return .degrees(360) + } + } + } + + /// An effect that spins the view when a change happens. + static var spin: AnyChangeEffect { + spin(axis: (0, 1, 0)) + } + + /// An effect that spins the view when a change happens. + /// + /// - Parameters: + /// - axis: The x, y and z elements that specify the axis of rotation. + /// - anchor: The location with a default of center that defines a point in 3D space about which the rotation is anchored. + /// - anchorZ: The location with a default of 0 that defines a point in 3D space about which the rotation is anchored. + /// - perspective: The relative vanishing point with a default of 1 / 6 for this rotation. + /// - rate: The rate of the spin. + static func spin(axis: (x: CGFloat, y: CGFloat, z: CGFloat), anchor: UnitPoint = .center, anchorZ: CGFloat = 0, perspective: CGFloat = 1 / 6, rate: SpinRate = .default) -> AnyChangeEffect { + .simulation { change in + SpinSimulationModifier(impulseCount: change, axis: axis, anchor: anchor, anchorZ: anchorZ, perspective: perspective, rate: rate) + } + } +} + +internal struct SpinSimulationModifier: ViewModifier, Simulative { + var impulseCount: Int + + var initialVelocity: CGFloat = 0 + + let spring = Spring(zeta: 1 / 2, stiffness: 7) + + var axis: (x: CGFloat, y: CGFloat, z: CGFloat) + + var anchor: UnitPoint + + var anchorZ: CGFloat + + var perspective: CGFloat + + var rate: AnyChangeEffect.SpinRate + + @State + private var targetAngle: Angle = .zero + + @State + private var angle: Angle = .zero + + @State + private var angleVelocity: Angle = .zero + + private var transformEffect: some ViewModifier { + Transform3DEffect( + translation: (0, 0, anchorZ), + angle: angle, + axis: (axis.x, axis.y, axis.z), + anchor: anchor, + anchorZ: anchorZ, + perspective: perspective + ) + .shaded(lightSource: (-0.5, -1, 0)) + } + + private var isSimulationPaused: Bool { + targetAngle == angle && abs(angleVelocity.degrees) <= 0.2 + } + + public func body(content: Content) -> some View { + TimelineView(.animation(paused: isSimulationPaused)) { context in + content + .modifier(transformEffect) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(max(0, min(duration, 1 / 30))) + } + } + } + .onChange(of: impulseCount) { newValue in + withAnimation(nil) { + if angleVelocity <= .degrees(10) { + angleVelocity = rate.initialVelocity + } else { + angleVelocity += rate.additionalVelocity + } + + angleVelocity = min(angleVelocity, rate.maximumVelocity) + } + } + } + + private func update(_ step: Double) { + let newValue: Double + let newVelocity: Double + + if abs(angleVelocity.degrees) > 240 { + newValue = angle.degrees + angleVelocity.degrees * step + newVelocity = angleVelocity.degrees * 0.99 + targetAngle = .degrees((angle.degrees / 360.0).rounded(.up) * 360.0) + } else if spring.response > 0 { + (newValue, newVelocity) = spring.value( + from: angle.degrees, + to: targetAngle.degrees, + velocity: angleVelocity.degrees, + timestep: step + ) + } else { + newValue = targetAngle.degrees + newVelocity = .zero + } + + angle = .degrees(newValue) + angleVelocity = .degrees(newVelocity) + + if abs(newValue - targetAngle.degrees) < 0.04, newVelocity < 0.04 { + angle = targetAngle + angleVelocity = .zero + } + } +} + +#if os(iOS) && DEBUG +struct SpinSimulation_Previews: PreviewProvider { + struct Preview: View { + @State + var impulseCount = 0 + + var body: some View { + + VStack { + ZStack { + Circle().fill(.red) + + Image(systemName: "circle.and.line.horizontal") + .font(.system(size: 40)) + .imageScale(.large) + .foregroundColor(.white) + } + .frame(width: 100, height: 100) + .changeEffect(.spin(axis: (1, 0, 0)), value: impulseCount) + + if #available(iOS 16.0, *) { + ZStack { + Image(systemName: "hand.thumbsup.fill") + .font(.system(size: 40)) + .imageScale(.large) + .foregroundStyle(.blue.gradient) + } + .changeEffect(.spin(axis: (0, 1, 0), rate: .fast), value: impulseCount) + .frame(width: 100, height: 100) + } + + ZStack { + Circle().fill(.yellow) + + Image(systemName: "circle.and.line.horizontal") + .rotationEffect(.degrees(90)) + .font(.system(size: 40)) + .imageScale(.large) + .foregroundColor(.white) + } + .frame(width: 100, height: 100) + .changeEffect(.spin(axis: (0, 1, 0)), value: impulseCount) + + ZStack { + Circle().fill(.green) + + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 40)) + .imageScale(.large) + .foregroundColor(.white) + } + .frame(width: 100, height: 100) + .changeEffect(.spin(axis: (0, 0, 1)), value: impulseCount) + + ZStack { + Circle().fill(.blue) + + Image(systemName: "circle.and.line.horizontal") + .rotationEffect(.degrees(45)) + .font(.system(size: 40)) + .imageScale(.large) + .foregroundColor(.white) + } + .frame(width: 100, height: 100) + .changeEffect(.spin(axis: (1, 1, 0)), value: impulseCount) + + ZStack { + Circle().fill(.blue) + + Image(systemName: "circle.and.line.horizontal") + .rotationEffect(.degrees(-45)) + .font(.system(size: 40)) + .imageScale(.large) + .foregroundColor(.white) + } + .frame(width: 100, height: 100) + .changeEffect(.spin(axis: (-1, 1, 0), anchorZ: -100), value: impulseCount) + + Button("Spin") { + impulseCount += 1 + } + .buttonStyle(.bordered) + } + .padding() + } + } + + struct Preview2: View { + @State + var text = "" + + var body: some View { + VStack(alignment: .trailing) { + VStack(alignment: .leading, spacing: 0) { + TextEditor(text: $text) + .mask({ + RoundedRectangle(cornerRadius: 8, style: .continuous) + }) + .overlay(content: { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(.gray) + }) + .frame(height: 140) + + Text(text.count.formatted()) + .font(.caption) + .monospacedDigit() + .foregroundColor(.white) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background(.gray, in: Capsule()) + .changeEffect(.spin(axis: (1, 0, 0), anchor: .top), value: text.count) + .mask(Rectangle()) + } + + Button("Send") { + + } + .buttonStyle(.borderedProminent) + #if os(iOS) + .buttonBorderShape(.capsule) + #endif + .tint(.green) + } + .padding() + } + } + + static var previews: some View { + Group { + NavigationView { + Preview() + } + + NavigationView { + Preview2() + } + } + } +} +#endif diff --git a/Sources/Pow/Effects/SprayEffect.swift b/Sources/Pow/Effects/SprayEffect.swift new file mode 100644 index 0000000..a6e98b8 --- /dev/null +++ b/Sources/Pow/Effects/SprayEffect.swift @@ -0,0 +1,460 @@ +import SwiftUI +import simd + +#if os(iOS) +import CoreHaptics +#endif + +public extension AnyChangeEffect { + /// An effect that emits multiple particles in different shades and sizes moving up from the origin point. + /// + /// - Parameters: + /// - origin: The origin of the particles. + /// - layer: The `ParticleLayer` on which to render the effect, default is `local`. + /// - particles: The particles to emit. + static func spray(origin: UnitPoint = .center, layer: ParticleLayer = .local, @ViewBuilder _ particles: () -> some View) -> AnyChangeEffect { + let particles = particles() + return .simulation({ change in + SpraySimulation(view: particles, impulseCount: change, origin: origin, layer: layer) + }) + } +} + +internal struct SpraySimulation: ViewModifier, Simulative { + var particle: ParticleView + + var impulseCount: Int = 0 + + var initialVelocity: CGFloat = 0.0 + + var origin: UnitPoint + + private let spring = Spring(zeta: 1, stiffness: 30) + + private struct Ping: Identifiable { + let id: UUID + var progress: Float + var velocity: Float + var target: Float + } + + @State + private var pings: [Ping] = [] + + private let layer: ParticleLayer + + @Environment(\.particleLayerNames) + var particleLayerNames + + init(view: ParticleView, impulseCount: Int, initialVelocity: CGFloat = 0.0, origin: UnitPoint = .center, layer: ParticleLayer) { + self.particle = view + self.impulseCount = impulseCount + self.initialVelocity = initialVelocity + self.origin = origin + self.layer = layer + } + + private var isSimulationPaused: Bool { + pings.isEmpty + } + + private struct _ViewContainer: SwiftUI._VariadicView.MultiViewRoot { + func body(children: _VariadicView.Children) -> some View { + ForEach(Array(zip(0..., children)), id: \.1.id) { offset, child in + child.tag(offset) + } + } + } + + func body(content: Content) -> some View { + let hasParticleLayer: Bool = { + if let name = layer.name, particleLayerNames.contains(name) { + return true + } else { + return false + } + }() + + let overlay = TimelineView(.animation(paused: isSimulationPaused)) { context in + let insets = EdgeInsets(top: 320, leading: 160, bottom: 40, trailing: 160) + + Canvas { context, size in + var symbols: [GraphicsContext.ResolvedSymbol] = [] + + var i = 0 + var nextSymbol: GraphicsContext.ResolvedSymbol? = context.resolveSymbol(id: i) + while let symbol = nextSymbol { + symbols.append(symbol) + i += 1 + nextSymbol = context.resolveSymbol(id: i) + } + + guard let symbol = symbols.first else { return } + + let symbolWidth = clamp(0, symbol.size.width, size.width / 6) + let symbolHeight = clamp(0, symbol.size.height, size.height / 8) + + context.translateBy(x: size.width / 2, y: insets.top + (size.height - insets.top - insets.bottom) / 2) + + let indices: SIMD16 = SIMD16(stride(from: 0.0, to: 16, by: 1)) + let scaleFactors: SIMD16 = SIMD16(stride(from: 0.0, to: 16, by: 1).map { (f: Float) in + f.truncatingRemainder(dividingBy: 5.0) / 5.0 + }) + let value: SIMD16 = indices / 10 + + /// To simply the expression :rolleyes: + let adjustedValue: SIMD16 = (value - 0.5) + + // in degrees + let angles: SIMD16 = value * 45 - 45 / 2.0 + + for ping in pings { + var rng = SeededRandomNumberGenerator(seed: ping.id) + + let symbolOffset = (0...10).randomElement(using: &rng) ?? 0 + + let value2: SIMD16 = SIMD16.random(in: 0.0 ... 1.0, using: &rng) + scaleFactors + + let insetAmount: Float = cos(ping.progress) * pow(ping.progress, 1) * -Float(symbolHeight) * 2.5 + + let phases: SIMD16 = (ping.progress * 0.75) + value2 + let sineScales: SIMD16 = simd_abs(sin(phases * SIMD16(repeating: .pi))) + let scales: SIMD16 = sineScales * (1.0 - pow(ping.progress, 8.0)) * pow(ping.progress, 0.25) + + let brightness: SIMD16 = .random(in: -0.1 ... 0.1, using: &rng) + + let x: SIMD16 = adjustedValue * (sin(ping.progress * Float.pi) * Float(symbolWidth) * -2) + let y: SIMD16 = insetAmount - (value2 * ping.progress) * Float(symbolHeight) * 2.5 + + for i in 0...10 { + let point = CGPoint(x: x[i], y: y[i]) + + let angle = Angle(degrees: angles[i]) + let scale = Double(scales[i]) + + let symbol = symbols[(i + symbolOffset) % symbols.count] + + // If we're drawing in the particle group, fade in the + // the particles as we're no longer drawing behind the + // view. + if hasParticleLayer { + context.opacity = clamp(Double(ping.progress) * 4) + } + + context.drawLayer { context in + context.addFilter(.brightness(Double(brightness[i]))) + + context.rotate(by: .degrees(Double(ping.progress) * -angle.degrees + -angle.degrees * 0.25)) + context.translateBy(x: point.x, y: point.y) + context.scaleBy(x: scale, y: scale) + context.rotate(by: .degrees(sqrt(Double(ping.progress) * 2) * angle.degrees - angle.degrees * 0.25)) + context.draw(symbol, at: .zero) + } + } + } + } symbols: { + SwiftUI._VariadicView.Tree(_ViewContainer()) { + particle + } + } + .padding(insets.inverse) + .modifier(RelativeOffsetModifier(anchor: origin)) + .allowsHitTesting(false) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(max(0, min(duration, 1 / 30))) + } + } + } + + content + .particleLayerBackground(layer: layer, isEnabled: !isSimulationPaused) { + overlay + } + .usesCustomHaptics() + .onChange(of: impulseCount) { newValue in + let ping = Ping( + id: UUID(), + progress: 0, + velocity: Float(initialVelocity), + target: 1.0 + ) + + withAnimation(nil) { + pings.append(ping) + } + + #if os(iOS) + if let hapticPattern { + Haptics.play(hapticPattern) + } + #endif + } + } + + private func update(_ step: Double) { + for index in pings.indices { + var ping = pings[index] + + if spring.response > 0 { + let (newValue, newVelocity) = spring.value( + from: ping.progress, + to: ping.target, + velocity: ping.velocity, + timestep: step + ) + ping.progress = newValue + ping.velocity = newVelocity + } else { + ping.progress = ping.target + ping.velocity = .zero + } + + pings[index] = ping + } + + pings.removeAll { ping in + abs(ping.progress - ping.target) < 0.04 && ping.velocity < 0.04 + } + } + + #if os(iOS) + private var hapticPattern: CHHapticPattern? { + var rng = SeededRandomNumberGenerator(seed: 123) + + return try? CHHapticPattern( + events: (0 ..< 5).map { i in + let i = Float(i) + + let relativeTime: TimeInterval + + if i == 0 { + relativeTime = 0 + } else { + relativeTime = Double(i * 0.03) + .random(in: -0.005 ... 0.005, using: &rng) + } + + return CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6 * (i / 5) + .random(in: -0.2 ... 0.2, using: &rng)), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2) + ], + relativeTime: relativeTime, + duration: 0.05 + ) + }, + parameterCurves: [] + ) + } + #endif +} + +private struct RelativeOffsetModifier: GeometryEffect { + var anchor: UnitPoint + + func effectValue(size: CGSize) -> ProjectionTransform { + let x = size.width * (-0.5 + anchor.x) + let y = size.height * (-0.5 + anchor.y) + + return ProjectionTransform( + CGAffineTransform(translationX: x, y: y) + ) + } +} + +private extension CGPoint { + init(x: Float, y: Float) { + self.init(x: CGFloat(x), y: CGFloat(y)) + } +} + +private extension Angle { + init(degrees: Float) { + self.init(degrees: Double(degrees)) + } +} + +#if os(iOS) && DEBUG +struct SprayChangeEffect_Previews: PreviewProvider { + struct Preview: View { + @State + var likesLarge: Int = 352 + + @State + var spells: Int = 139 + + @State + var stars: Int = 953 + + @State + var claps: Int = 238 + + @State + var plus1s: Int = 574 + + var body: some View { + VStack { + GroupBox { + Button { + likesLarge += 1 + } label: { + HStack { + Image(systemName: "dice.fill") + .rotationEffect(.degrees(-15)) + Text(likesLarge.formatted()) + } + .font(.largeTitle) + } + .buttonStyle(.bordered) + .changeEffect(.spray(origin: UnitPoint(x: 0.25, y: 0.25), { + Group { + Image(systemName: "suit.heart.fill").foregroundColor(.red) + Image(systemName: "suit.club.fill").foregroundColor(.black) + Image(systemName: "suit.spade.fill").foregroundColor(.black) + Image(systemName: "suit.diamond.fill").foregroundColor(.red) + } + .font(.largeTitle) + }), value: likesLarge) + .tint(.green) + .frame(maxWidth: .infinity, maxHeight: 240, alignment: .bottom) + } + + HStack { + GroupBox { + let particle = Image(systemName: "sparkle").foregroundColor(.purple) + + Button { + spells += 1 + } label: { + HStack { + Image(systemName: "wand.and.stars") + + Text("\(spells, format: .number)") + } + } + .changeEffect(.spray(origin: UnitPoint(x: 0.25, y: 0.5)) { particle }, value: spells) + .buttonStyle(.bordered) + .tint(.purple) + .frame(maxWidth: .infinity, maxHeight: 150, alignment: .bottom) + } + .environment(\.layoutDirection, .rightToLeft) + .environment(\.locale, .init(identifier: "ar_EG")) + + GroupBox { + Button { + stars += 1 + } label: { + Image(systemName: "star.fill") + .changeEffect(.spray({ Image(systemName: "star.fill") }), value: stars) + Text(stars.formatted()) + } + .buttonStyle(.bordered) + .tint(.orange) + .frame(maxWidth: .infinity, maxHeight: 150, alignment: .bottom) + } + } + + HStack { + GroupBox { + Button { + claps += 1 + } label: { + Image(systemName: "hands.clap.fill") + .changeEffect(.spray({ Image(systemName: "person") }).delay(1), value: claps) + .changeEffect(.spray({ Image(systemName: "rays") }).delay(0), value: claps) + Text(claps.formatted()) + } + .buttonStyle(.bordered) + .tint(.green) + .frame(maxWidth: .infinity, maxHeight: 150, alignment: .bottom) + } + + GroupBox { + Button { + plus1s += 1 + } label: { + Image(systemName: "plus.circle.fill") + .changeEffect(.spray({ Image(systemName: "plus") }), value: plus1s) + Text(plus1s.formatted()) + } + .buttonStyle(.bordered) + .tint(.orange) + .frame(maxWidth: .infinity, maxHeight: 150, alignment: .bottom) + } + } + } + .monospacedDigit() + .padding() + } + } + + struct ListPreview: View { + @State + var claps: [Int: Int] = [:] + + var body: some View { + NavigationView { + List { + Section("Unclipped") { + ForEach(0 ..< 5) { i in + HStack { + Text("Cell #\(i)") + Spacer() + + Button { + claps[i, default: 0] += 1 + } label: { + Label(claps[i, default: 0].formatted(), systemImage: "heart.fill") + } + .monospacedDigit() + .controlSize(.small) + .buttonBorderShape(.capsule) + .changeEffect(.spray(layer: .named("root")) { + Image(systemName: "heart.fill").foregroundStyle(.tint) + .tint(.pink) + }, value: claps[i, default: 0]) + } + } + } + + Section("Clipped") { + ForEach(0 ..< 5) { i in + HStack { + Text("Cell #\(i)") + Spacer() + + Button { + claps[i, default: 0] += 1 + } label: { + Label(claps[i, default: 0].formatted(), systemImage: "heart.fill") + } + .monospacedDigit() + .controlSize(.small) + .buttonBorderShape(.capsule) + .changeEffect(.spray { + Image(systemName: "heart.fill").foregroundStyle(.tint) + .tint(.pink) + }, value: claps[i, default: 0]) + } + } + } + } + .labelStyle(.titleOnly) + .buttonStyle(.borderedProminent) + .navigationTitle("Cells") + } + .particleLayer(name: "root") + } + } + + static var previews: some View { + Preview() + + ListPreview() + .environment(\.colorScheme, .dark) + .previewDisplayName("Escaping List") + } +} +#endif diff --git a/Sources/Pow/Effects/WiggleEffect.swift b/Sources/Pow/Effects/WiggleEffect.swift new file mode 100644 index 0000000..7cad1d8 --- /dev/null +++ b/Sources/Pow/Effects/WiggleEffect.swift @@ -0,0 +1,173 @@ +import SwiftUI + +public extension AnyChangeEffect { + /// An effect that wiggles the view when a change happens. + static var wiggle: AnyChangeEffect { + wiggle(rate: .default) + } + + /// The rate of the wiggle effect. + enum WiggleRate { + case `default` + case fast + + fileprivate var phaseLength: CGFloat { + switch self { + case .default: return 0.8 + case .fast: return 0.3 + } + } + } + + /// An effect that wiggles the view when a change happens. + /// + /// - Parameter rate: The rate of the wiggle. + static func wiggle(rate: WiggleRate) -> AnyChangeEffect { + .simulation({ change in + WiggleSimulationModifier(impulseCount: change, phaseLength: rate.phaseLength) + }) + } +} + +internal struct WiggleSimulationModifier: ViewModifier, Simulative { + // TODO: Not used, remove from protocol + var initialVelocity: CGFloat = 0 + + var impulseCount: Int + + var phaseLength: CGFloat + + @Environment(\.isConditionalEffect) + private var isConditionalEffect + + @State + private var wiggleCount: CGFloat = 0 + + @State + private var displacement: CGFloat = 0 + + @State + private var integrator: SecondOrderDynamics = SecondOrderDynamics( + f: 3, + zeta: 0.85, + r: -0.2 + ) + + fileprivate var target: CGFloat { + 16 * sin(2 * .pi * wiggleCount) + } + + private var isSimulationPaused: Bool { + displacement == .zero && wiggleCount <= 0 + } + + public func body(content: Content) -> some View { + let t = Transform3DEffect( + angle: .degrees(displacement / 2), + axis: (0, 0, 1) + ) + + TimelineView(.animation(paused: isSimulationPaused)) { context in + content + .modifier(t) + .onChange(of: context.date) { (newValue: Date) in + let duration = Double(newValue.timeIntervalSince(context.date)) + withAnimation(nil) { + update(max(0, min(duration, 1 / 30))) + } + } + } + .onChange(of: impulseCount) { newValue in + withAnimation(nil) { + wiggleCount += 2 + + if wiggleCount > 3 { + wiggleCount = 2 + fmod(wiggleCount, 1) + } + + if isConditionalEffect { + wiggleCount += 4 + } + } + } + } + + private func update(_ step: Double) { + displacement = integrator.update(target: target, timestep: step) + + if !displacement.isNormal { + displacement = 0 + } + + wiggleCount = clamp(0, wiggleCount - 2 * (step / phaseLength), .infinity) + } +} + + +#if os(iOS) && DEBUG +struct WiggleEffect_Previews: PreviewProvider { + struct Preview: View { + @State + var value: Int = 0 + + var body: some View { + VStack(spacing: 8) { + Spacer() + + VStack(spacing: 32) { + Label("Answer", systemImage: "phone.fill") + .foregroundColor(.white) + .padding() + .background(.green, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .changeEffect(.wiggle(rate: .fast), value: value) + .changeEffect(.shine(angle: .degrees(90), duration: 0.75), value: value) + .tint(.green) + .font(.largeTitle) + } + + Spacer() + + Stepper(value: $value) { + Text("Value ") + Text("(\(value.formatted()))").foregroundColor(.secondary) + } + } + .padding() + } + } + + struct Preview2: View { + @State + var isCalling: Bool = false + + var body: some View { + VStack(spacing: 8) { + Spacer() + + VStack(spacing: 32) { + Label("Answer", systemImage: "phone.fill") + .foregroundColor(.white) + .padding() + .background(.green, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .conditionalEffect(.repeat(.wiggle(rate: .fast), every: 2), condition: isCalling) + .tint(.green) + .font(.largeTitle) + } + + Spacer() + + Toggle("Calling", isOn: $isCalling) + } + .padding() + } + } + + static var previews: some View { + Preview() + .preferredColorScheme(.dark) + .previewDisplayName("Change Effect") + Preview2() + .preferredColorScheme(.dark) + .previewDisplayName("Conditional Effect") + } +} +#endif diff --git a/Sources/Pow/Extensions/Animation+TimingCurves.swift b/Sources/Pow/Extensions/Animation+TimingCurves.swift new file mode 100644 index 0000000..4367b23 --- /dev/null +++ b/Sources/Pow/Extensions/Animation+TimingCurves.swift @@ -0,0 +1,104 @@ +import SwiftUI + +public extension Animation.MovingParts { + /// A timing curve that anticipates animating to the target. + static var anticipate: Animation { + anticipate(duration: 0.35) + } + + /// A timing curve that anticipates animating to the target. + static func anticipate(duration: Double) -> Animation { + .timingCurve(0.33, 0, 0.66, -0.55, duration: duration) + } + + /// A timing curve that overshoots the target. + static var overshoot: Animation { + overshoot(duration: 0.35) + } + + /// A timing curve that overshoots the target. + static func overshoot(duration: Double) -> Animation { + .timingCurve(0.33, 1.55, 0.66, 1, duration: duration) + } + + /// A timing curve that anticipates animating to the target and overshoots + /// it. + static var anticipateOvershoot: Animation { + anticipateOvershoot(duration: 0.35) + } + + /// A timing curve that anticipates animating to the target and overshoots + /// it. + static func anticipateOvershoot(duration: Double) -> Animation { + .timingCurve(0.66, -0.55, 0.33, 1.6, duration: duration) + } +} + +public extension Animation.MovingParts { + static var easeInExponential: Animation { + easeInExponential(duration: 0.35) + } + + static func easeInExponential(duration: Double) -> Animation { + .timingCurve(0.95, 0.05, 0.795, 0.035, duration: duration) + } + + static var easeOutExponential: Animation { + easeOutExponential(duration: 0.35) + } + + static func easeOutExponential(duration: Double) -> Animation { + .timingCurve(0.19, 1, 0.22, 1, duration: duration) + } + + static var easeInOutExponential: Animation { + easeInOutExponential(duration: 0.35) + } + + static func easeInOutExponential(duration: Double) -> Animation { + .timingCurve(1, 0, 0, 1, duration: duration) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct TimingCurves_Previews: PreviewProvider { + struct Preview: View { + @State + var isOn: Bool = false + + var body: some View { + let shape = Rectangle() + .fill(.red) + .frame(width: 64, height: 64) + .frame(maxWidth: .infinity, alignment: isOn ? .trailing : .leading) + + VStack { + Toggle(isOn: $isOn) { Text("Toggle Me") } + + shape + .animation(.easeInOut, value: isOn) + + shape + .animation(.movingParts.easeInExponential, value: isOn) + + shape + .animation(.movingParts.anticipate, value: isOn) + + shape + .animation(.movingParts.overshoot, value: isOn) + + shape + .animation(.movingParts.anticipateOvershoot, value: isOn) + + Spacer() + } + .padding() + } + } + + static var previews: some View { + Preview() + } +} +#endif diff --git a/Sources/Pow/Extensions/CGAffineTransform+Shear.swift b/Sources/Pow/Extensions/CGAffineTransform+Shear.swift new file mode 100644 index 0000000..ae2d242 --- /dev/null +++ b/Sources/Pow/Extensions/CGAffineTransform+Shear.swift @@ -0,0 +1,13 @@ +import CoreGraphics + +extension CGAffineTransform { + init(shearX x: CGFloat, y: CGFloat) { + self = .identity + self.c = x + self.b = y + } +} + +func CGAffineTransformShear(_ t: CGAffineTransform, _ x: CGFloat, _ y: CGFloat) -> CGAffineTransform { + t.concatenating(CGAffineTransform(shearX: x, y: y)) +} diff --git a/Sources/Pow/Extensions/CGPoint+Utilities.swift b/Sources/Pow/Extensions/CGPoint+Utilities.swift new file mode 100644 index 0000000..9a5b974 --- /dev/null +++ b/Sources/Pow/Extensions/CGPoint+Utilities.swift @@ -0,0 +1,11 @@ +import SwiftUI + +extension CGPoint { + func distance(to other: CGPoint) -> CGFloat { + sqrt((x - other.x) * (x - other.x) + (y - other.y) * (y - other.y)) + } + + func angle(to other: CGPoint) -> Angle { + Angle(radians: atan2(other.y - y, other.x - x)) + } +} diff --git a/Sources/Pow/Extensions/CGRect+Utilities.swift b/Sources/Pow/Extensions/CGRect+Utilities.swift new file mode 100644 index 0000000..2966ccb --- /dev/null +++ b/Sources/Pow/Extensions/CGRect+Utilities.swift @@ -0,0 +1,40 @@ +import SwiftUI + +extension CGRect { + init(center: CGPoint, size: CGSize) { + let origin = CGPoint( + x: center.x - size.width / 2, + y: center.y - size.height / 2 + ) + + self.init(origin: origin, size: size) + } + + var center: CGPoint { + CGPoint(x: midX, y: midY) + } + + var diagonal: CGFloat { + sqrt(width * width + height * height) + } + + func boundingBox(at angle: Angle) -> CGRect { + CGRect(center: center, size: size.boundingSize(at: angle)) + } + + var topLeft: CGPoint { + CGPoint(x: minX, y: minY) + } + + var topRight: CGPoint { + CGPoint(x: maxX, y: minY) + } + + var bottomRight: CGPoint { + CGPoint(x: maxX, y: maxY) + } + + var bottomLeft: CGPoint { + CGPoint(x: minX, y: maxY) + } +} diff --git a/Sources/Pow/Extensions/CGSize+Utilities.swift b/Sources/Pow/Extensions/CGSize+Utilities.swift new file mode 100644 index 0000000..f20d78a --- /dev/null +++ b/Sources/Pow/Extensions/CGSize+Utilities.swift @@ -0,0 +1,29 @@ +import SwiftUI + +extension CGSize { + var area: CGFloat { + width * height + } + + func boundingSize(at angle: Angle) -> CGSize { + var theta: Double = angle.radians + + let sizeA: CGSize = CGSize( + width: abs(width * cos(Double(theta)) + height * sin(Double(theta))), + height: abs(width * sin(Double(theta)) + height * cos(Double(theta))) + ) + + theta += .pi / 2 + + let sizeB: CGSize = CGSize( + width: abs(width * sin(Double(theta)) + height * cos(Double(theta))), + height: abs(width * cos(Double(theta)) + height * sin(Double(theta))) + ) + + if sizeA.area > sizeB.area { + return sizeA + } else { + return sizeB + } + } +} diff --git a/Sources/Pow/Extensions/Duration+TimeInterval.swift b/Sources/Pow/Extensions/Duration+TimeInterval.swift new file mode 100644 index 0000000..7375eb6 --- /dev/null +++ b/Sources/Pow/Extensions/Duration+TimeInterval.swift @@ -0,0 +1,9 @@ +import Foundation + +@available(iOS 16.0, *) +@available(macOS 13.0, *) +internal extension Duration { + var timeInterval: TimeInterval { + TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 + } +} diff --git a/Sources/Pow/Extensions/ProjectionTransform+Utilities.swift b/Sources/Pow/Extensions/ProjectionTransform+Utilities.swift new file mode 100644 index 0000000..2ab5c62 --- /dev/null +++ b/Sources/Pow/Extensions/ProjectionTransform+Utilities.swift @@ -0,0 +1,15 @@ +import simd +import SwiftUI + +internal extension ProjectionTransform { + init(_ m: simd_double4x4) { + let d = CATransform3D( + m11: m[0][0], m12: m[0][1], m13: m[0][2], m14: m[0][3], + m21: m[1][0], m22: m[1][1], m23: m[1][2], m24: m[1][3], + m31: m[2][0], m32: m[2][1], m33: m[2][2], m34: m[2][3], + m41: m[3][0], m42: m[3][1], m43: m[3][2], m44: m[3][3] + ) + + self.init(d) + } +} diff --git a/Sources/Pow/Extensions/UnitPoint+CircularCoordinates.swift b/Sources/Pow/Extensions/UnitPoint+CircularCoordinates.swift new file mode 100644 index 0000000..0f1f7d2 --- /dev/null +++ b/Sources/Pow/Extensions/UnitPoint+CircularCoordinates.swift @@ -0,0 +1,25 @@ +import SwiftUI + +internal extension UnitPoint { + /// Creates a `UnitPoint` from a point on the Unit Circle. + /// + /// > Note: The Unit Circle has a radius of 1 and is centered around + /// > `(0, 0)` whereas SwiftUI's `UnitPoint` is definde in the Unit Square + /// > which has sides of length 1 and a center of `(0.5, 0.5)`. + /// + /// For the point to lie on the circle, it needs to fulfil `u² + v² == 1`. + /// + /// - Parameters: + /// - u: The horizontal coordinate. + /// - v: The vertical coordinate. + init(u: Double, v: Double) { + let u_2: Double = pow(u, 2) + let v_2: Double = pow(v, 2) + let sq2: Double = sqrt(2.0) + + let x: Double = 0.5 * sqrt(abs(2.0 + u_2 - v_2 + 2.0 * u * sq2)) - 0.5 * sqrt(abs(2.0 + u_2 - v_2 - 2.0 * u * sq2)) + let y: Double = 0.5 * sqrt(abs(2.0 - u_2 + v_2 + 2.0 * v * sq2)) - 0.5 * sqrt(abs(2.0 - u_2 + v_2 - 2.0 * v * sq2)) + + self.init(x: (1 + x) / 2, y: (1 + y) / 2) + } +} diff --git a/Sources/Pow/Extensions/ViewModifier+DefaultAnimation.swift b/Sources/Pow/Extensions/ViewModifier+DefaultAnimation.swift new file mode 100644 index 0000000..0861dbf --- /dev/null +++ b/Sources/Pow/Extensions/ViewModifier+DefaultAnimation.swift @@ -0,0 +1,11 @@ +import SwiftUI + +internal extension ViewModifier where Self: Animatable { + func defaultAnimation(_ animation: Animation) -> some ViewModifier { + transaction { t in + if t.animation == .default { + t.animation = animation + } + } + } +} diff --git a/Sources/Pow/Extensions/simd+Utilities.swift b/Sources/Pow/Extensions/simd+Utilities.swift new file mode 100644 index 0000000..3a2766d --- /dev/null +++ b/Sources/Pow/Extensions/simd+Utilities.swift @@ -0,0 +1,20 @@ +import simd + +internal extension simd_double4x4 { + init(translationX x: Double, y: Double, z: Double = 0) { + self.init(diagonal: [1, 1, 1, 1]) + + self[3][0] = x + self[3][1] = y + self[3][2] = z + } + + init(scaleX x: Double, y: Double, z: Double = 0) { + self.init(diagonal: [x, y, z, 1]) + } + + init(perspective: Double) { + self.init(diagonal: [1, 1, 1, 1]) + self[2][3] = -perspective / 100 + } +} diff --git a/Sources/Pow/Infrastructure/AngleControl.swift b/Sources/Pow/Infrastructure/AngleControl.swift new file mode 100644 index 0000000..3fa31e4 --- /dev/null +++ b/Sources/Pow/Infrastructure/AngleControl.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct AngleControl: View { + @Binding + var angle: Angle + + var label: Label + + init(angle: Binding, @ViewBuilder label: () -> Label) { + self._angle = angle + self.label = label() + } + + @State + private var lastAngle: Angle = .zero + + @GestureState + private var dragAngle: Angle = .zero + + @Environment(\.controlSize) + private var controlSize + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 0) + .updating($dragAngle) { value, state, _ in + state = .degrees(-value.translation.height * 2) + } + .onChanged { value in + angle = lastAngle + .degrees(-value.translation.height * 2) + } + .onEnded { value in + angle = lastAngle + .degrees(-value.translation.height * 2) + lastAngle = angle + } + } + + private var size: CGFloat { + switch controlSize { + case .mini: return 32 + case .small: return 38 + case .regular: return 44 + case .large: return 54 + case .extraLarge: return 54 + @unknown default: return 44 + } + } + + var body: some View { + let content = ZStack { + Circle() + .fill(.gray.opacity(dragAngle == .zero ? 0.1 : 0.2)) + .animation(.easeOut(duration: dragAngle == .zero ? 0.3 : 0.05), value: dragAngle == .zero) + Circle() + .stroke(.quaternary) + ZStack(alignment: .leading) { + Color.clear + Capsule(style: .continuous) + .fill(.tint) + .frame(width: size / 4, height: 2) + .padding(4) + } + .rotationEffect(angle) + } + .frame(width: size, height: size) + + if #available(iOS 16.0, macOS 13, *) { + LabeledContent { + content + } label: { + label + } + .gesture(dragGesture) + } else { + content + .gesture(dragGesture) + } + } +} + +extension AngleControl where Label == Text { + init(_ title: some StringProtocol, angle: Binding) { + self._angle = angle + self.label = Text(title) + } + + init(_ titleKey: LocalizedStringKey, angle: Binding) { + self._angle = angle + self.label = Text(titleKey) + } + + init(angle: Binding) { + self._angle = angle + let measurement = Measurement(value: angle.wrappedValue.degrees, unit: .degrees) + let formatted = measurement + .formatted( + .measurement( + width: .narrow, + numberFormatStyle: .number.precision(.fractionLength(0)) + ) + ) + self.label = Text(formatted) + } +} + +struct AngleControl_Previews: PreviewProvider { + struct Preview: View { + @State var angle: Angle = .zero + + var body: some View { + VStack { + Rectangle() + .fill(.red) + .frame(width: 100, height: 1) + .rotationEffect(angle) + + AngleControl(angle: $angle) + } + .monospacedDigit() + } + } + + static var previews: some View { + VStack(spacing: 32) { + ForEach(ControlSize.allCases, id: \.self) { size in + Preview() + .controlSize(size) + } + } + .padding() + } +} diff --git a/Sources/Pow/Infrastructure/AnyAnimatableViewModifier.swift b/Sources/Pow/Infrastructure/AnyAnimatableViewModifier.swift new file mode 100644 index 0000000..1f91c3a --- /dev/null +++ b/Sources/Pow/Infrastructure/AnyAnimatableViewModifier.swift @@ -0,0 +1,24 @@ +import SwiftUI + +internal struct AnyAnimatableViewModifier: ViewModifier, Animatable { + private var _body: (Content) -> AnyView + + var animatableData: EmptyAnimatableData + + init(_ modifier: Modifier) { + self._body = { content in + AnyView(content.modifier(modifier)) + } + self.animatableData = .zero + } + + func body(content: Content) -> AnyView { + _body(content) + } +} + +internal extension ViewModifier where Self: Animatable { + func eraseToAnyAnimatableViewModifier() -> AnyAnimatableViewModifier { + AnyAnimatableViewModifier(self) + } +} diff --git a/Sources/Pow/Infrastructure/AnyChangeEffect.swift b/Sources/Pow/Infrastructure/AnyChangeEffect.swift new file mode 100644 index 0000000..6f6ea7f --- /dev/null +++ b/Sources/Pow/Infrastructure/AnyChangeEffect.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// A type-erased change effect. +public struct AnyChangeEffect { + private var modifier: (Int) -> AnyViewModifier + + private var animation: Animation? + + internal var cooldown: Double + + internal var delay: Double = 0 + + fileprivate init(modifier: @escaping (Int) -> AnyViewModifier, animation: Animation?, cooldown: Double) { + self.modifier = modifier + self.animation = animation + self.cooldown = cooldown + } + + internal func viewModifier(changeCount: Int) -> some ViewModifier { + modifier(changeCount) + .animation(animation) + } + + public func delay(_ delay: Double) -> Self { + var copy = self + copy.delay = delay + + return copy + } +} + +extension AnyChangeEffect { + static func animation(_ makeModifier: @escaping (Int) -> Modifier, animation: Animation? = .default, cooldown: Double = 0.33) -> AnyChangeEffect { + AnyChangeEffect( + modifier: { change in + makeModifier(change) + .eraseToAnyViewModifier() + }, + animation: animation, + cooldown: cooldown + ) + } + + static func simulation(_ makeModifier: @escaping (Int) -> Modifier) -> AnyChangeEffect { + AnyChangeEffect(modifier: { change in + makeModifier(change).eraseToAnyViewModifier() + }, animation: nil, cooldown: 0.0) + } +} diff --git a/Sources/Pow/Infrastructure/AnyContinuousEffect.swift b/Sources/Pow/Infrastructure/AnyContinuousEffect.swift new file mode 100644 index 0000000..995f00a --- /dev/null +++ b/Sources/Pow/Infrastructure/AnyContinuousEffect.swift @@ -0,0 +1,39 @@ +import SwiftUI + +internal struct AnyContinuousEffect { + private var _viewModifier: (Bool) -> AnyContinuousViewModifier + + static func modifier(_ modifier: @escaping (Bool) -> some ViewModifier & Continuous) -> Self { + AnyContinuousEffect(_viewModifier: { isActive in + modifier(isActive).eraseToAnyContinuousViewModifier() + }) + } + + func viewModifier(_ isActive: Bool) -> AnyContinuousViewModifier { + _viewModifier(isActive) + } +} + +internal struct AnyContinuousViewModifier: ViewModifier { + private var _body: (AnyView) -> AnyView + + init(_ modifier: Modifier) { + self._body = { content in + AnyView(content.modifier(modifier)) + } + } + + func body(content: Content) -> AnyView { + _body(AnyView(content)) + } +} + +internal extension ViewModifier where Self: Continuous { + func eraseToAnyContinuousViewModifier() -> AnyContinuousViewModifier { + AnyContinuousViewModifier(self) + } +} + +internal protocol Continuous { + var isActive: Bool { get } +} diff --git a/Sources/Pow/Infrastructure/AnyViewModifier.swift b/Sources/Pow/Infrastructure/AnyViewModifier.swift new file mode 100644 index 0000000..7b4a6d8 --- /dev/null +++ b/Sources/Pow/Infrastructure/AnyViewModifier.swift @@ -0,0 +1,21 @@ +import SwiftUI + +internal struct AnyViewModifier: ViewModifier { + private var _body: (Content) -> AnyView + + init(_ modifier: Modifier) { + self._body = { content in + AnyView(content.modifier(modifier)) + } + } + + func body(content: Content) -> AnyView { + _body(content) + } +} + +internal extension ViewModifier { + func eraseToAnyViewModifier() -> AnyViewModifier { + AnyViewModifier(self) + } +} diff --git a/Sources/Pow/Infrastructure/Haptics.swift b/Sources/Pow/Infrastructure/Haptics.swift new file mode 100644 index 0000000..fb659ff --- /dev/null +++ b/Sources/Pow/Infrastructure/Haptics.swift @@ -0,0 +1,65 @@ +import SwiftUI + +#if os(iOS) +import CoreHaptics + +internal struct Haptics { + private static var engine: CHHapticEngine? = { + return try? CHHapticEngine() + }() + + private static var supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics + + private static var counter: Int = 0 { + didSet { + guard supportsHaptics else { return } + + if oldValue == 0 && counter == 1 { + #if DEBUG + print("[Pow] Starting haptics engine.") + #endif + + try? engine?.start() + } else if counter == 0 { + #if DEBUG + print("[Pow] Stopping haptics engine.") + #endif + + engine?.stop() + } + } + } + + static func acquire() { + counter += 1 + } + + static func release() { + counter -= 1 + } + + static func play(_ pattern: CHHapticPattern, at time: TimeInterval = CHHapticTimeImmediate) { + let player = try? engine?.makePlayer(with: pattern) + + try? player?.start(atTime: time) + } +} + +internal extension View { + func usesCustomHaptics() -> some View { + modifier( + _AppearanceActionModifier { + Haptics.acquire() + } disappear: { + Haptics.release() + } + ) + } +} +#else +internal extension View { + func usesCustomHaptics() -> Self { + self + } +} +#endif diff --git a/Sources/Pow/Infrastructure/MathUtilities.swift b/Sources/Pow/Infrastructure/MathUtilities.swift new file mode 100644 index 0000000..a2c3691 --- /dev/null +++ b/Sources/Pow/Infrastructure/MathUtilities.swift @@ -0,0 +1,124 @@ +import Foundation +import CoreGraphics + +internal func rubberClamp(_ min: CGFloat, _ value: CGFloat, _ max: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat { + let clamped = clamp(min, value, max) + + let delta = abs(clamped - value) + + guard delta != 0 else { + return value + } + + let sign: CGFloat = clamped > value ? -1 : 1 + + let range = (max - min) + + return clamped + sign * (1.0 - (1.0 / ((delta * coefficient / range) + 1.0))) * range +} + +internal func clamp(_ min: C, _ value: C, _ max: C) -> C { + Swift.max(min, Swift.min(value, max)) +} + +internal func clamp(_ value: F) -> F { + clamp(0, value, 1) +} + +internal func map(value: T, inMin: T, inMax: T, outMin: T, outMax: T) -> T { + return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin +} + +internal func lerp(_ value: T, outMin: T, outMax: T) -> T { + return map(value: value, inMin: 0, inMax: 1, outMin: outMin, outMax: outMax) +} + +internal func easeOut(_ t: CGFloat) -> CGFloat { + pow(t - 1, 3) + 1 +} + +internal func easeInCubic(_ t: CGFloat) -> CGFloat { + t * t * t +} + +internal func easeInOutCubic(_ t: CGFloat) -> CGFloat { + if t < 0.5 { + return 4 * pow(t, 3) + } else { + return (t - 1) * pow(2 * t - 2, 2) + 1 + } +} + +internal func easeInOutQuart(_ t: CGFloat) -> CGFloat { + if t < 0.5 { + return 8 * pow(t, 4) + } else { + return -1 / 2 * pow(2 * t - 2, 4) + 1 + } +} + +func cubicBezier(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat) -> (CGFloat) -> CGFloat { + func A(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + 1.0 - 3.0 * a2 + 3.0 * a1 + } + + func B(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + 3.0 * a2 - 6.0 * a1 + } + + func C(_ a1: CGFloat) -> CGFloat { + 3.0 * a1 + } + + func cubicBezierCalculate(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + ((A(a1, a2) * t + B(a1, a2)) * t + C(a1)) * t + } + + func cubicBezierSlope(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat { + 3 * A(a1, a2) * t * t + 2 * B(a1, a2) * t + C(a1) + } + + func binarySubdivide(_ x: CGFloat, _ x1: CGFloat, _ x2: CGFloat) -> CGFloat { + let epsilon = 0.0000001 + let maxIterations = 10 + + var start: CGFloat = 0 + var end: CGFloat = 1 + + var currentX: CGFloat = 0 + var currentT: CGFloat = 0 + + var i = 0 + + while true { + currentT = start + (end - start) / 2; + currentX = cubicBezierCalculate(currentT, x1, x2) - x; + + if (currentX > 0) { + end = currentT; + } else { + start = currentT; + } + + i += 1 + + if (fabs(currentX) > epsilon && i < maxIterations) { + + } else { + break + } + } + + return currentT; + } + + if (x1 == y1 && x2 == y2) { + return { $0 } + } + + return { x in + let t = binarySubdivide(x, x1, x2) + + return cubicBezierCalculate(t, y1, y2) + } +} diff --git a/Sources/Pow/Infrastructure/Namespace.swift b/Sources/Pow/Infrastructure/Namespace.swift new file mode 100644 index 0000000..e1af3ad --- /dev/null +++ b/Sources/Pow/Infrastructure/Namespace.swift @@ -0,0 +1,24 @@ +import SwiftUI +import AVFoundation + +public extension Animation { + enum MovingParts { + + } + + /// The namespace of Moving Parts animations. + static var movingParts: MovingParts.Type { + MovingParts.self + } +} + +public extension AnyTransition { + enum MovingParts { + + } + + /// The namespace of Moving Parts transitions. + static var movingParts: MovingParts.Type { + MovingParts.self + } +} diff --git a/Sources/Pow/Infrastructure/OnChangeEffect.swift b/Sources/Pow/Infrastructure/OnChangeEffect.swift new file mode 100644 index 0000000..39edda2 --- /dev/null +++ b/Sources/Pow/Infrastructure/OnChangeEffect.swift @@ -0,0 +1,154 @@ +import Foundation +import SwiftUI +import Dispatch + +public extension View { + /// Applies the given change effect to this view when the specified value changes. + /// + /// - Parameters: + /// - effect: The effect to apply. + /// - value: A value to monitor for changes. + /// - isEnabled: A Boolean value that indicates whether the effect should be applied when the value changes. Defaults to `true`. + /// + /// - Returns: A view that applies the effect to this view whenever value changes. + @ViewBuilder + func changeEffect(_ effect: AnyChangeEffect, value: V, isEnabled: @autoclosure @escaping () -> Bool = true) -> some View { + modifier(HighlightChangeModifier(value, effect: effect, predicate: { _ in isEnabled() })) + } +} + +struct HighlightChangeModifier: ViewModifier { + var value: Value + + var effect: AnyChangeEffect + + var predicate: (Value) -> Bool + + @State + private var changeCount: Int = 0 + + @State + private var lastUpdate: Date = .distantPast + + init(_ value: Value, effect: AnyChangeEffect, predicate: @escaping (Value) -> Bool) { + self.value = value + self.effect = effect + self.predicate = predicate + } + + func body(content: Content) -> some View { + let t = effect.viewModifier(changeCount: changeCount) + let cooldown = effect.cooldown + let delay = effect.delay + + func update(_ newValue: Value) { + guard predicate(newValue), value != newValue else { return } + + guard lastUpdate.timeIntervalSinceNow < -cooldown else { return } + lastUpdate = .now + + changeCount += 1 + } + + return content + .onChange(of: value) { newValue in + if delay == 0 { + update(newValue) + } else { + let when = DispatchQueue.SchedulerTimeType(DispatchTime.now() + delay) + + DispatchQueue.main.schedule(after: when, tolerance: 0.016) { + update(newValue) + } + } + } + .modifier(t) + } +} + +#if os(iOS) && DEBUG +struct OnChangeEffectPreview_Previews: PreviewProvider { + struct Preview: View { + @State + var value: Int = 0 + + @State + var delay: Double = 0 + + var body: some View { + VStack(spacing: 8) { + GroupBox { + Stepper(value: $value) { + Text("Value ") + Text("(\(value.formatted()))").foregroundColor(.secondary) + } + + Stepper(value: $value.animation(.easeInOut)) { + Text("Value (animated) ") + Text("(\(value.formatted()))").foregroundColor(.secondary) + } + + Slider(value: $delay, in: -2 ... 2) + } + + VStack(spacing: 32) { + Label("Shine (Default)", systemImage: "arrow.forward.square") + .foregroundColor(.white) + .padding() + .background(.blue) + .changeEffect(.shine.delay(delay), value: value) + + Label("Ping", systemImage: "arrow.forward.square") + .foregroundColor(.white) + .padding() + .background(.green, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .changeEffect(.pulse(shape: RoundedRectangle(cornerRadius: 16, style: .continuous), count: 3), value: value) + .tint(.green) + + Label("Jump", systemImage: "arrow.forward.square") + .foregroundColor(.white) + .padding() + .background(.orange, in: Capsule(style: .continuous)) + .changeEffect(.jump(height: 50), value: value) + + Label("Spin Simulation", systemImage: "arrow.forward.square") + .foregroundColor(.white) + .padding() + .background(.red, in: Capsule(style: .continuous)) + .changeEffect(.spin, value: value) + + HStack { + let effect = AnyChangeEffect.spray { + Image(systemName: "heart.fill") + .foregroundColor(.pink) + .font(.system(size: 40)) + } + + Label("Spray", systemImage: "sparkles") + .foregroundColor(.white) + .padding() + .background(.blue, in: Capsule(style: .continuous)) + + .changeEffect(effect, value: value) + + Label("Spray (delay)", systemImage: "sparkles") + .foregroundColor(.white) + .padding() + .background(.blue, in: Capsule(style: .continuous)) + .changeEffect(effect.delay(0.5), value: value) + } + + Label("Shake", systemImage: "arrow.left.arrow.right") + .foregroundColor(.white) + .padding() + .background(.purple, in: Capsule(style: .continuous)) + .changeEffect(.shake, value: value) + } + } + .padding() + } + } + + static var previews: some View { + Preview() + } +} +#endif diff --git a/Sources/Pow/Infrastructure/ParticleLayer.swift b/Sources/Pow/Infrastructure/ParticleLayer.swift new file mode 100644 index 0000000..042a2d9 --- /dev/null +++ b/Sources/Pow/Infrastructure/ParticleLayer.swift @@ -0,0 +1,219 @@ +import SwiftUI + +public extension View { + /// Wraps this view in a particle layer with the given name. + /// + /// Particle effects such as `AnyChangeEffect.spray` can render their particles on this position in the view tree to avoid being clipped by their immediate ancestor. + /// + /// For example, certain `List` styles may clip their rows. Use `particleLayer(_:)` to render particles on top of the entire `List` or even its enclosing `NavigationStack`. + func particleLayer(name: AnyHashable) -> some View { + self + .transformEnvironment(\.particleLayerNames) { + $0.insert(name) + } + .overlayPreferenceValue(ParticleLayerPreferenceKey.self) { p in + GeometryReader { proxy in + let keys: [UUID] = { + return p + .filter { $0.1.name == name } + .keys + .sorted { a, b in a.uuidString < b.uuidString } + }() + + ZStack { + ForEach(keys, id: \.self) { key in + let layer = p[key]! + let b = proxy[layer.bounds] + + layer.erasedContent + .frame(width: b.width, height: b.height) + .position(b.center) + } + } + } + } + .transformPreference(ParticleLayerPreferenceKey.self) { + $0 = $0.filter { $0.1.name != name } + } + } +} + +/// A context in which particle effects draw their particles. +public struct ParticleLayer: Hashable { + internal enum Guts: Hashable { + case local + case named(AnyHashable) + } + + var guts: Guts + + var name: AnyHashable? { + switch guts { + case .named(let name): return name + case .local: return nil + } + } + + /// A `ParticleLayer` with a given name. + public static func named(_ name: AnyHashable) -> Self { + Self(guts: .named(name)) + } + + /// The local particle layer. + public static var local: Self { + Self(guts: .local) + } +} + +internal struct ParticleLayerContents { + var name: AnyHashable + + var content: any View + + var bounds: Anchor + + var erasedContent: AnyView { + AnyView(erasing: content) + } +} + +internal struct ParticleLayerPreferenceKey: PreferenceKey { + static var defaultValue: [UUID: ParticleLayerContents] = [:] + + static func reduce(value: inout [UUID: ParticleLayerContents], nextValue: () -> [UUID: ParticleLayerContents]) { + value.merge(nextValue()) { _, b in b } + } +} + +internal extension EnvironmentValues { + struct ParticleLayerNames: EnvironmentKey { + static var defaultValue: Set = [] + } + + var particleLayerNames: Set { + get { self[ParticleLayerNames.self] } + set { self[ParticleLayerNames.self] = newValue } + } +} + +internal extension View { + func particleLayerBackground(alignment: Alignment = .center, layer: ParticleLayer = .local, isEnabled: Bool = true, @ViewBuilder particle: () -> some View) -> some View { + modifier(ParticleLayerBackgroundModifier(alignment: alignment, layer: layer, isEnabled: isEnabled, particle: particle)) + } + + func particleLayerOverlay(alignment: Alignment = .center, layer: ParticleLayer = .local, isEnabled: Bool = true, @ViewBuilder particle: () -> some View) -> some View { + modifier(ParticleLayerOverlayModifier(alignment: alignment, layer: layer, isEnabled: isEnabled, particle: particle)) + } +} + +private struct ParticleLayerBackgroundModifier: ViewModifier { + var alignment: Alignment + + var particle: Particle + + var layer: ParticleLayer + + var isEnabled: Bool + + @State + private var layerID = UUID() + + @Environment(\.self) + private var wholeEnvironment + + @Environment(\.particleLayerNames) + private var particleLayerNames + + init(alignment: Alignment, layer: ParticleLayer, isEnabled: Bool, @ViewBuilder particle: () -> Particle) { + self.alignment = alignment + self.layer = layer + self.isEnabled = isEnabled + self.particle = particle() + } + + func body(content: Content) -> some View { + let hasParticleLayer: Bool = { + if let name = layer.name, particleLayerNames.contains(name) { + return true + } else { + return false + } + }() + + content + .background(alignment: alignment) { + if !hasParticleLayer { + particle + } + } + .anchorPreference(key: ParticleLayerPreferenceKey.self, value: .bounds) { bounds in + if let name = layer.name, hasParticleLayer, isEnabled { + return [ + layerID: ParticleLayerContents( + name: name, + content: particle.environment(\.self, wholeEnvironment), + bounds: bounds + ) + ] + } else { + return [:] + } + } + } +} + +private struct ParticleLayerOverlayModifier: ViewModifier { + var alignment: Alignment + + var particle: Particle + + var layer: ParticleLayer + + var isEnabled: Bool + + @State + private var layerID = UUID() + + @Environment(\.self) + private var wholeEnvironment + + @Environment(\.particleLayerNames) + private var particleLayerNames + + init(alignment: Alignment, layer: ParticleLayer, isEnabled: Bool, @ViewBuilder particle: () -> Particle) { + self.alignment = alignment + self.layer = layer + self.isEnabled = isEnabled + self.particle = particle() + } + + func body(content: Content) -> some View { + let hasParticleLayer: Bool = { + if let name = layer.name, particleLayerNames.contains(name) { + return true + } else { + return false + } + }() + + content + .overlay(alignment: alignment) { + if !hasParticleLayer { + particle + } + } + .anchorPreference(key: ParticleLayerPreferenceKey.self, value: .bounds) { bounds in + if let name = layer.name, hasParticleLayer, isEnabled { + return [ + layerID: ParticleLayerContents( + name: name, + content: particle.environment(\.self, wholeEnvironment), + bounds: bounds + ) + ] + } else { + return [:] + } + } + } +} diff --git a/Sources/Pow/Infrastructure/Scaled.swift b/Sources/Pow/Infrastructure/Scaled.swift new file mode 100644 index 0000000..bf0ebe3 --- /dev/null +++ b/Sources/Pow/Infrastructure/Scaled.swift @@ -0,0 +1,28 @@ +import SwiftUI + + +/// Scales the domain of a View Modifier to avoid snapping when animating with a spring animation. +internal struct Scaled: ViewModifier, Animatable { + var animatableData: V.AnimatableData { + get { + var v = base.animatableData + v.scale(by: 64) + return v + } + set { + var v = newValue + v.scale(by: 1 / 64) + base.animatableData = v + } + } + + var base: V + + init(_ base: V) { + self.base = base + } + + func body(content: Content) -> some View { + content.modifier(base.animation(nil)) + } +} diff --git a/Sources/Pow/Infrastructure/SecondOrderDynamics.swift b/Sources/Pow/Infrastructure/SecondOrderDynamics.swift new file mode 100644 index 0000000..7eeca74 --- /dev/null +++ b/Sources/Pow/Infrastructure/SecondOrderDynamics.swift @@ -0,0 +1,52 @@ +import SwiftUI + +internal struct SecondOrderDynamics { + var k1: Double + + var k2: Double + + var k3: Double + + var previousTarget: V + + var value: V + + var velocity: V = .zero + + /// - Parameters: + /// - f: The natural frequence, in Hz. + /// - zeta: The damping coefficient. + /// - r: The initial response of the system. + init(f: Double = 1, zeta: Double = 0.5, r: Double = 2, x0: V = .zero) { + self.k1 = zeta / (.pi * f) + self.k2 = 1 / pow(2 * .pi * f, 2) + self.k3 = (r * zeta) / (2 * .pi * f) + + self.previousTarget = x0 + self.value = x0 + } + + mutating func update(target: V, timestep: TimeInterval) -> V { + let xd = (target - previousTarget) / timestep + previousTarget = target + + let stableK2 = max(k2, 1.1 * (timestep * timestep / 4 + timestep * k1 / 2)) + + value = value + velocity * timestep + velocity = velocity + ((target + (xd * k3) - value - (velocity * k1)) / stableK2) * timestep + + return value + } +} + +private func * (lhs: V, rhs: Double) -> V { + var copy = lhs + copy.scale(by: rhs) + return copy +} + +private func / (lhs: V, rhs: Double) -> V { + var copy = lhs + copy.scale(by: 1 / rhs) + return copy +} diff --git a/Sources/Pow/Infrastructure/SeededRandomNumberGenerator.swift b/Sources/Pow/Infrastructure/SeededRandomNumberGenerator.swift new file mode 100644 index 0000000..e8f549b --- /dev/null +++ b/Sources/Pow/Infrastructure/SeededRandomNumberGenerator.swift @@ -0,0 +1,53 @@ +import Foundation + +final class SeededRandomNumberGenerator : RandomNumberGenerator { + private struct PCGRand32 { + static let _multiplier: UInt64 = 0x5851f42d4c957f2d + + var state: UInt64 = 0x853c49e6748fea9b + var increment: UInt64 = 0xda3e39cb94b95bdb + + mutating func seed(initializer: UInt64, sequence: UInt64) { + state = 0 + increment = (sequence << 1) | 1 + step() + state = state &+ initializer + step() + } + + mutating func step() { + state = state &* PCGRand32._multiplier &+ increment + } + + mutating func next() -> UInt32 { + defer { + step() + } + + let shifted = UInt32(truncatingIfNeeded: ((state >> 18) ^ state) >> 27) + let rotation = UInt32(truncatingIfNeeded: state >> 59) + + return (shifted >> rotation) | (shifted << ((~rotation &+ 1) & 31)) + } + } + + private var a: PCGRand32 + + private var b: PCGRand32 + + convenience init(seed value: H) { + self.init(seed: UInt64(truncatingIfNeeded: value.hashValue)) + } + + init(seed: UInt64) { + a = PCGRand32() + a.seed(initializer: seed, sequence: 666) + + b = PCGRand32() + b.seed(initializer: seed, sequence: 123) + } + + public func next() -> UInt64 { + return UInt64(a.next()) << 32 | UInt64(b.next()) + } +} diff --git a/Sources/Pow/Infrastructure/Simulative.swift b/Sources/Pow/Infrastructure/Simulative.swift new file mode 100644 index 0000000..dc55400 --- /dev/null +++ b/Sources/Pow/Infrastructure/Simulative.swift @@ -0,0 +1,27 @@ +import SwiftUI + +protocol Simulative { + var impulseCount: Int { get set } + + var initialVelocity: CGFloat { get set } +} + +internal struct AnySimulativeViewModifier: ViewModifier { + private var _body: (AnyView) -> AnyView + + init(_ modifier: Modifier) { + self._body = { content in + AnyView(content.modifier(modifier)) + } + } + + func body(content: Content) -> AnyView { + _body(AnyView(content)) + } +} + +internal extension ViewModifier where Self: Simulative { + func eraseToAnySimulativeViewModifier() -> AnySimulativeViewModifier { + AnySimulativeViewModifier(self) + } +} diff --git a/Sources/Pow/Infrastructure/Spring.swift b/Sources/Pow/Infrastructure/Spring.swift new file mode 100644 index 0000000..1e94e91 --- /dev/null +++ b/Sources/Pow/Infrastructure/Spring.swift @@ -0,0 +1,261 @@ +import SwiftUI + +internal struct Spring { + var mass: Double + + var stiffness: Double + + var zeta: Double + + init(zeta: Double, stiffness: Double, mass: Double = 1.0) { + self.zeta = zeta + self.stiffness = stiffness + self.mass = mass + } + + func value(from source: V, to target: V, velocity: V = .zero, timestep: TimeInterval) -> (V, V) { + let displacement = source - target + let springForce = displacement * -stiffness + let dampingForce = velocity * -dampingCoefficient + let force = springForce + dampingForce + let acceleration = force / mass + + let newVelocity = velocity + acceleration * timestep + let newValue = source + newVelocity * timestep + + return (newValue, newVelocity) + } + + func value(initialPosition x0: V, initialVelocity v0: V, at t: TimeInterval) -> V { + let unit: V + + switch (x0, v0) { + case (.zero, .zero): + return .zero + case (.zero, let v0) where v0 != .zero: + unit = v0 / sqrt(v0.magnitudeSquared) + case (let x0, _): + unit = x0 / sqrt(x0.magnitudeSquared) + } + + let m: Double = sqrt(x0.magnitudeSquared) + let v: Double = sqrt(v0.magnitudeSquared) + + let s: Double + + if zeta < 1 { + s = -exp(-delta * t) * (m * cos(omega1 * t) + ((delta * m + v) / omega1) * sin(omega1 * t)) + } else if zeta == 1 { + s = -exp(-delta * t) * (m + (delta * m + v) * t) + } else { + s = -exp(-delta * t) * (m * cosh(omega2 * t) + ((delta * m + v) / omega2) * sinh(omega2 * t)) + } + + return x0 + unit * s + } + + func peakTime(initialPosition x0: V, initialVelocity v0: V) -> Double { + guard x0 != .zero || v0 != .zero else { return 0 } + + if zeta < 1 { + guard v0 != .zero else { return .pi / omega1 } + + let m: Double = sqrt(x0.magnitudeSquared) + let v: Double = sqrt(v0.magnitudeSquared) + + func derivative(t: Double) -> Double { + (exp(-delta * t) * (-omega1 * v * cos(omega1 * t) + (v * delta + m * (pow(omega1, 2) + pow(delta, 2))) * sin(omega1 * t))) / omega1 + } + + return clamp(0, secantMethod(f: derivative, 0, period / (.pi * stiffness)), 3) + } else { + guard v0 != .zero else { return 0 } + + // TODO: Calculate correct peak for non-underdamped springs with + // `v0 != .zero` + return 0 + } + } +} + +internal extension Spring { + var response: Double { + (2 * .pi) / sqrt(stiffness * mass) + } +} + +private extension Spring { + var dampingCoefficient: Double { + 4 * .pi * zeta * mass / response + } + + var criticalDampingCoefficient: Double { + 2 * sqrt(stiffness * mass) * zeta + } + + var delta: Double { + criticalDampingCoefficient / (2 * mass) + } + + var omega0: Double { + sqrt(stiffness / mass) + } + + var omega1: Double { + sqrt(omega0 * omega0 - delta * delta) + } + + var omega2: Double { + sqrt(delta * delta - omega0 * omega0) + } + + var period: Double { + 2 * .pi * sqrt(stiffness / mass) + } +} + + +/// Calculate an approximation for the root of `f` between `x0` and `x1`. +private func secantMethod(f: (Double) -> Double, _ x0: Double, _ x1: Double) -> Double { + let epsilon = 0.01 + + var xN = x1 - (f(x1) * (x1 - x0)) / (f(x1)-f(x0)) + var x0 = x1 + var x1 = xN + + while abs(f(xN)) > epsilon { + xN = x1 - (f(x1) * (x1 - x0)) / (f(x1)-f(x0)) + x0 = x1 + x1 = xN + } + + return xN +} + +private func * (lhs: V, rhs: Double) -> V { + var copy = lhs + copy.scale(by: rhs) + return copy +} + +private func / (lhs: V, rhs: Double) -> V { + var copy = lhs + copy.scale(by: 1 / rhs) + return copy +} + +private prefix func - (value: V) -> V { + var copy = value + copy.scale(by: -1) + return copy +} + +#if os(iOS) && DEBUG +import Charts + +@available(iOS 16.0, *) +struct Spring_Previews: PreviewProvider { + struct Sample: Identifiable { + var x: Double + var y: Double + + var id: some Hashable { x } + } + + struct Example: Identifiable { + var spring: Spring + + var id: some Hashable { + spring.zeta + } + + var name: String { + "zeta:\(spring.zeta)" + } + } + + struct Preview: View { + @State + var showDerivatives: Bool = false + + var body: some View { + VStack { + Toggle("Derivatives", isOn: $showDerivatives) + + let springs = [ + Example(spring: Spring(zeta: 0.01, stiffness: 10, mass: 2)), + Example(spring: Spring(zeta: 0.33, stiffness: 10, mass: 2)), + Example(spring: Spring(zeta: 0.66, stiffness: 10, mass: 2)), + Example(spring: Spring(zeta: 0.99, stiffness: 10, mass: 2)), + ] + + let x0: Double = 0 + let v0: Double = 10 + + let xs = stride(from: 0, through: 3, by: 0.01) + + Chart(springs) { example in + let spring = example.spring + + let f = { (t: Double) -> Double in + spring.value(initialPosition: x0, initialVelocity: v0, at: t) + } + + let p = spring.peakTime(initialPosition: x0, initialVelocity: v0) + + let samples = xs.map { + Sample( + x: $0, + y: f($0) + ) + } + + ForEach(samples) { sample in + LineMark( + x: .value("x", sample.x), + y: .value("y", sample.y), + series: .value("spring", example.name) + ) + .foregroundStyle(by: .value("f", example.name)) + } + + if showDerivatives { + let derivative: [Sample] = xs.map { (t: Double) -> Sample in + let m = x0 + let v = v0 + + let y: Double = (exp(spring.delta * (-t)) * (sin(t * spring.omega1) * (m * (pow(spring.delta, 2) + pow(spring.omega1, 2)) + spring.delta * v) - v * spring.omega1 * cos(t * spring.omega1)))/spring.omega1 + + return Sample( + x: t, + y: y + ) + } + + ForEach(derivative) { sample in + LineMark( + x: .value("x", sample.x), + y: .value("y", sample.y), + series: .value("spring", example.name + "'") + ) + .foregroundStyle(by: .value("f'", example.name + "'")) + } + } + + PointMark( + x: .value("x", p), + y: .value("y", f(p)) + ) + .foregroundStyle(by: .value("peakTime", example.name)) + } + .aspectRatio(1, contentMode: .fit) + } + .padding() + } + } + + static var previews: some View { + Preview() + } +} +#endif diff --git a/Sources/Pow/Infrastructure/TRS.swift b/Sources/Pow/Infrastructure/TRS.swift new file mode 100644 index 0000000..ec544a2 --- /dev/null +++ b/Sources/Pow/Infrastructure/TRS.swift @@ -0,0 +1,76 @@ +import SwiftUI +import simd + +internal struct TRS: Equatable { + var translation: simd_double3 = .zero + + var rotation: simd_quatd = .init() + + var scale: simd_double3 = .zero + + init() {} + + init(translation: simd_double3, rotation: simd_quatd, scale: simd_double3) { + self.translation = translation + self.rotation = rotation + self.scale = scale + } +} + +extension TRS { + static let identity = TRS(translation: [0, 0, 0], rotation: .init(), scale: [1, 1, 1]) +} + +extension TRS { + var viewNormal: simd_double3 { + let s: simd_double4 = [0, 0, 1, 0] + + let translation = simd_double4x4(translationX: translation.x, y: translation.y, z: translation.z) + + let rotation = simd_double4x4(rotation.normalized) + + let scale = simd_double4x4(scaleX: scale.x, y: scale.y, z: scale.z) + + let r = ((translation * rotation) * scale) * s + + return [r.x, r.y, r.z] + } +} + +extension TRS: VectorArithmetic { + mutating func scale(by rhs: Double) { + translation *= rhs + rotation *= rhs + + scale *= rhs + } + + var magnitudeSquared: Double { + (translation * translation).sum() + + rotation.real * rotation.real + + (rotation.imag * rotation.imag).sum() + + (scale * scale).sum() + } + + static var zero: Self { + Self() + } + + static func + (lhs: Self, rhs: Self) -> Self { + var result = Self() + result.translation = lhs.translation + rhs.translation + result.rotation = lhs.rotation + rhs.rotation + result.scale = lhs.scale + rhs.scale + + return result + } + + static func - (lhs: Self, rhs: Self) -> Self { + var result = Self() + result.translation = lhs.translation - rhs.translation + result.rotation = lhs.rotation - rhs.rotation + result.scale = lhs.scale - rhs.scale + + return result + } +} diff --git a/Sources/Pow/Infrastructure/Transform3DEffect.swift b/Sources/Pow/Infrastructure/Transform3DEffect.swift new file mode 100644 index 0000000..90eb9d9 --- /dev/null +++ b/Sources/Pow/Infrastructure/Transform3DEffect.swift @@ -0,0 +1,317 @@ +import SwiftUI +import simd + +internal struct Transform3DEffect: GeometryEffect, Animatable { + var animatableData: AnimatablePair> = .zero + + init(translation: simd_double3 = .zero, rotation: simd_quatd = simd_quatd(angle: 0, axis: .zero), scale: simd_double3 = [1, 1, 1], anchor: UnitPoint = .center, anchorZ: Double = 0, perspective: Double = 1) { + self.animatableData.first = TRS(translation: translation, rotation: rotation, scale: scale) + self.animatableData.second.first = Anchor3D(xy: anchor, z: anchorZ) + self.animatableData.second.second = perspective + } + + init( + translation: (x: Double, y: Double, z: Double) = (0, 0, 0), + angle: Angle = .zero, + axis: (x: Double, y: Double, z: Double) = (0, 0, 0), + scale: (x: Double, y: Double, z: Double) = (1, 1, 1), + anchor: UnitPoint = .center, + anchorZ: Double = 0.0, + perspective: Double = 1 + ) { + self.animatableData.first = TRS( + translation: [translation.x, translation.y, translation.z], + rotation: .init(angle: angle.radians, axis: [axis.x, axis.y, axis.z]), + scale: [scale.x, scale.y, scale.z] + ) + self.animatableData.second.first = Anchor3D(xy: anchor, z: anchorZ) + self.animatableData.second.second = perspective + } + + init(animatableData: AnimatableData) { + self.animatableData = animatableData + } + + private var trs: TRS { + get { animatableData.first } + set { animatableData.first = newValue } + } + + private var anchor: Anchor3D { + get { animatableData.second.first } + set { animatableData.second.first = newValue } + } + + private var perspective: Double { + get { animatableData.second.second } + set { animatableData.second.second = newValue } + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let offset = simd_double4x4(translationX: size.width * anchor.xy.x, y: size.height * anchor.xy.y, z: anchor.z) + + let perspective = simd_double4x4(perspective: perspective) + + let translation = simd_double4x4(translationX: trs.translation.x, y: trs.translation.y, z: trs.translation.z) + + let rotation = simd_double4x4(trs.rotation.normalized) + + let scale = simd_double4x4(scaleX: trs.scale.x, y: trs.scale.y, z: trs.scale.z) + + return ProjectionTransform((((offset * (perspective * translation)) * rotation) * scale) * offset.inverse) + } + + var shaded: ShadedTransform3DEffect { + ShadedTransform3DEffect(animatableData: animatableData) + } + + func shaded(lightSource: (x: Double, y: Double, z: Double)) -> ShadedTransform3DEffect { + ShadedTransform3DEffect(animatableData: animatableData, lightSource: lightSource) + } +} + +extension Transform3DEffect { + internal struct Anchor3D: Equatable { + var xy: UnitPoint = .center + + var z: Double = 0 + } +} + +extension Transform3DEffect.Anchor3D: VectorArithmetic { + mutating func scale(by rhs: Double) { + xy.x *= rhs + xy.y *= rhs + z *= rhs + } + + var magnitudeSquared: Double { + xy.x * xy.x + xy.y * xy.y + z * z + } + + static var zero: Self { + Self(xy: .zero) + } + + static func + (lhs: Self, rhs: Self) -> Self { + var result = Self() + result.xy.x = lhs.xy.x + rhs.xy.x + result.xy.y = lhs.xy.y + rhs.xy.y + result.z = lhs.z + rhs.z + + return result + } + + static func - (lhs: Self, rhs: Self) -> Transform3DEffect.Anchor3D { + var result = Self() + result.xy.x = lhs.xy.x - rhs.xy.x + result.xy.y = lhs.xy.y - rhs.xy.y + result.z = lhs.z - rhs.z + + return result + } +} + +internal struct ShadedTransform3DEffect: ViewModifier, Animatable { + var animatableData: Transform3DEffect.AnimatableData + + var lightSource: (x: Double, y: Double, z: Double) = (0, -1, 0) + + fileprivate init(animatableData: AnimatableData = .zero, lightSource: (x: Double, y: Double, z: Double) = (0, -1, 0)) { + self.animatableData = animatableData + self.lightSource = lightSource + } + + private var trs: TRS { + get { animatableData.first } + set { animatableData.first = newValue } + } + + func body(content: Content) -> some View { + let normal = animatableData.first.viewNormal + + let lightVector = simd_double3(lightSource.x, lightSource.y, lightSource.z) + let screenVector = simd_double3(0, 0, 1) + + let n: CGFloat = { + if dot(normal, screenVector) >= 0 { + return dot(lightVector, normal) + } else { + return dot(lightVector, -normal) + } + }() + + content + .brightness(n * 0.2) + .compositingGroup() + .modifier(Transform3DEffect(animatableData: animatableData)) + } +} + +#if os(iOS) && DEBUG +@available(iOS 16.0, *) +struct Transform3DEffect_Preview: PreviewProvider { + struct Preview: View { + @State + var anchor: (x: Double, y: Double, z: Double) = (0.5, 0.5, 0) + + @State + var translation: (x: Double, y: Double, z: Double) = (0, 0, 0) + + @State + var angle: (x: Angle, y: Angle, z: Angle) = (.zero, .zero, .zero) + + @State + var scale: (x: Double, y: Double, z: Double) = (1, 1, 1) + + @State + var perspective: CGFloat = 0.16 + + var body: some View { + let x = simd_quatd(angle: angle.x.radians, axis: [1, 0, 0]) + let y = simd_quatd(angle: angle.y.radians, axis: [0, 1, 0]) + let z = simd_quatd(angle: angle.z.radians, axis: [0, 0, 1]) + + let t = Transform3DEffect( + translation: [translation.x, translation.y, translation.z + anchor.z], + rotation: (x * y * z), + scale: [scale.x, scale.y, scale.z], + anchor: UnitPoint(x: anchor.x, y: anchor.y), + anchorZ: anchor.z, + perspective: perspective + ) + .shaded + + VStack(alignment: .leading) { + Grid(alignment: .leading) { + GridRow { + Text("Perspective") + Slider(value: $perspective, in: 0 ... 1) + } + + GridRow { + Text("anchor.x") + Slider(value: $anchor.x, in: 0 ... 1) + } + + GridRow { + Text("anchor.y") + Slider(value: $anchor.y, in: 0 ... 1) + } + GridRow { + Text("anchor.z") + Slider(value: $anchor.z, in: -40 ... 40) + } + + +// GridRow { +// Text("translation.x") +// Slider(value: $translation.x, in: -150 ... 150) +// } +// +// GridRow { +// Text("translation.y") +// Slider(value: $translation.y, in: -150 ... 150) +// } +// GridRow { +// Text("translation.z") +// Slider(value: $translation.z, in: -150 ... 150) +// } + + GridRow { + Label("Pitch", systemImage: "trapezoid.and.line.vertical") + Slider(value: $angle.x.degrees, in: -180 ... 180) + } + + GridRow { + Label("Roll", systemImage: "circle.and.line.horizontal") + Slider(value: $angle.z.degrees, in: -180 ... 180) + } + + GridRow { + Label("Yaw", systemImage: "trapezoid.and.line.horizontal") + Slider(value: $angle.y.degrees, in: -180 ... 180) + } + } + + HStack { + Button { + withAnimation(.interpolatingSpring(stiffness: 30, damping: 5)) { + perspective = 0.16 + anchor = (0.5, 0.5, 0) + translation = (0, 0, 0) + angle = (.zero, .zero, .zero) + } + } label: { + Label("Reset", systemImage: "arrow.uturn.backward") + } + + Button { + withAnimation(.interpolatingSpring(stiffness: 30, damping: 5)) { + angle.x = .degrees(.random(in: -180 ... 180)) + angle.y = .degrees(.random(in: -180 ... 180)) + angle.z = .degrees(.random(in: -180 ... 180)) + } + } label: { + Label("Shuffle", systemImage: "dice") + } + } + .buttonStyle(.bordered) + + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.blue.gradient) + .overlay { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .strokeBorder(.black.opacity(0.3), lineWidth: 4) + } + .overlay { + HStack { + Spacer() + Color.white.frame(width: 1) + Spacer() + Color.white.frame(width: 1) + Spacer() + Color.white.frame(width: 1) + Spacer() + Color.white.frame(width: 1) + Spacer() + } + .opacity(0.5) + } + .overlay { + VStack { + Spacer() + Color.white.frame(height: 1) + Spacer() + Color.white.frame(height: 1) + Spacer() + Color.white.frame(height: 1) + Spacer() + Color.white.frame(height: 1) + Spacer() + } + .opacity(0.5) + } + .overlay { + Text("Hello\nWorld") + .font(.system(size: 40, design: .rounded).bold()) + .foregroundColor(.white) + .multilineTextAlignment(.center) + } + .aspectRatio(1, contentMode: .fit) + .padding(40) + .compositingGroup() + .modifier(t) + .offset(y: -15) + + Spacer() + } + .padding(.horizontal) + } + } + + static var previews: some View { + Preview() + } +} +#endif diff --git a/Sources/Pow/Infrastructure/ViewRepresentable.swift b/Sources/Pow/Infrastructure/ViewRepresentable.swift new file mode 100644 index 0000000..3bcd886 --- /dev/null +++ b/Sources/Pow/Infrastructure/ViewRepresentable.swift @@ -0,0 +1,35 @@ +import SwiftUI + +#if os(iOS) || os(tvOS) +protocol ViewRepresentable: UIViewRepresentable { + associatedtype ViewType = UIViewType + func makeView(context: Context) -> ViewType + func updateView(_ view: ViewType, context: Context) +} + +extension ViewRepresentable { + func makeUIView(context: Context) -> ViewType { + makeView(context: context) + } + + func updateUIView(_ uiView: ViewType, context: Context) { + updateView(uiView, context: context) + } +} +#elseif os(macOS) +protocol ViewRepresentable: NSViewRepresentable { + associatedtype ViewType = NSViewType + func makeView(context: Context) -> ViewType + func updateView(_ view: ViewType, context: Context) +} + +extension ViewRepresentable { + func makeNSView(context: Context) -> ViewType { + makeView(context: context) + } + + func updateNSView(_ nsView: ViewType, context: Context) { + updateView(nsView, context: context) + } +} +#endif diff --git a/Sources/Pow/Infrastructure/WhileEffect.swift b/Sources/Pow/Infrastructure/WhileEffect.swift new file mode 100644 index 0000000..2de7d02 --- /dev/null +++ b/Sources/Pow/Infrastructure/WhileEffect.swift @@ -0,0 +1,255 @@ +import SwiftUI +import Combine + +public extension View { + /// Applies the given change effect to this view while a condition is `true`. + /// + /// - Parameters: + /// - effect: The effect to apply. + /// - condition: A boolean that indicates whether the effect is active. + /// + /// - Returns: A view that applies the effect to this view when `isActive` is `true`. + @ViewBuilder + func conditionalEffect(_ effect: AnyConditionalEffect, condition: Bool) -> some View { + switch effect.guts { + case .continuous(let effect): + modifier(ContinuousEffectModifier(effect: effect, isActive: condition)) + .environment(\.isConditionalEffect, true) + case .repeating(let effect, let interval): + modifier(RepeatingChangeEffectModifier(effect: effect, interval: interval, isActive: condition)) + .environment(\.isConditionalEffect, true) + } + } +} + +public struct AnyConditionalEffect { + internal enum Guts { + case continuous(AnyContinuousEffect) + case repeating(AnyChangeEffect, TimeInterval) + } + + internal var guts: Guts + + private init(guts: Guts) { + self.guts = guts + } + + internal static func continuous(_ effect: AnyContinuousEffect) -> AnyConditionalEffect { + AnyConditionalEffect(guts: .continuous(effect)) + } + + /// Repeats a change effect at the specified interval while a condition is true. + /// + /// - Parameters: + /// - effect: The change effect to repeat. + /// - interval: The number of seconds between each change effect. + public static func `repeat`(_ effect: AnyChangeEffect, every interval: TimeInterval) -> AnyConditionalEffect { + AnyConditionalEffect(guts: .repeating(effect, interval)) + } + + /// Repeats a change effect at the specified interval while a condition is true. + /// + /// - Parameters: + /// - effect: The change effect to repeat. + /// - interval: The duration between each change effect. + @available(iOS 16.0, *) + @available(macOS 13.0, *) + public static func `repeat`(_ effect: AnyChangeEffect, every interval: Duration) -> AnyConditionalEffect { + AnyConditionalEffect(guts: .repeating(effect, interval.timeInterval)) + } +} + +private struct ContinuousEffectModifier: ViewModifier { + + var effect: AnyContinuousEffect + + var isActive: Bool + + @State + private var changeCount: Int = 0 + + @State + private var startDate: Date = .distantPast + + func body(content: Content) -> some View { + content + .modifier(effect.viewModifier(isActive)) + } +} + +private struct RepeatingChangeEffectModifier: ViewModifier { + private final class RepeatingTimer: ObservableObject { + @Published + var count: Int = 0 + + var timer: Timer? { + willSet { + timer?.invalidate() + } + } + + init() {} + + func resume(interval: TimeInterval, delay: TimeInterval = 0) { + if delay != 0 { + timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] t in + self?.resume(interval: interval) + } + } else { + count += 1 + + reschedule(interval: interval) + } + } + + private func reschedule(interval: TimeInterval) { + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] t in + self?.resume(interval: interval) + } + } + + func pause() { + timer = nil + } + } + + var effect: AnyChangeEffect + + var interval: TimeInterval + + @StateObject + private var timer = RepeatingTimer() + + private var isEnabled: Bool + + init(effect: AnyChangeEffect, interval: TimeInterval, isActive: Bool) { + self.effect = effect + self.interval = clamp(1 / 15, interval, .infinity) + self.isEnabled = isActive && interval > 0 + } + + func body(content: Content) -> some View { + content + .modifier(effect.viewModifier(changeCount: timer.count)) + .onAppear { + if isEnabled { + timer.resume(interval: interval, delay: effect.delay) + } + } + .onChange(of: isEnabled) { isEnabled in + if isEnabled { + timer.resume(interval: interval, delay: effect.delay) + } else { + timer.pause() + } + } + .onChange(of: interval) { interval in + if isEnabled { + timer.resume(interval: interval, delay: effect.delay) + } + } + } +} + +internal extension EnvironmentValues { + private struct IsConditionalEffectKey: EnvironmentKey { + static var defaultValue: Bool = false + } + + var isConditionalEffect: Bool { + get { self[IsConditionalEffectKey.self] } + set { self[IsConditionalEffectKey.self] = newValue } + } +} + +#if os(iOS) && DEBUG +struct WhileEffectPreview_Previews: PreviewProvider { + private struct Preview: View { + @State + private var isEnabled: Bool = false + + var body: some View { + GroupBox("iOS 15") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + Button { + + } label: { + Label("Answer", systemImage: "phone.fill") + } + .tint(.green) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.repeat(.shake(rate: .fast), every: 1), condition: isEnabled) + } + } + .padding() + } + } + + @available(iOS 16.0, *) + private struct Preview16: View { + @State + private var isEnabled: Bool = false + + var body: some View { + GroupBox("iOS 16") { + VStack { + Toggle("Enabled", isOn: $isEnabled) + + Button { + + } label: { + Label("Answer", systemImage: "phone.fill") + } + .tint(.green) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.repeat(.wiggle(rate: .fast), every: .seconds(1.5)), condition: isEnabled) + + Button { + + } label: { + Label("Alert", systemImage: "light.beacon.max.fill") + } + .tint(.red) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.repeat(.glow(color: .red, radius: 50), every: .seconds(1)), condition: isEnabled) + + Button { + + } label: { + Label("Press", systemImage: "hand.raised.fingers.spread.fill") + } + .tint(.blue) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.pushDown, condition: isEnabled) + + Button { + + } label: { + Label("Burn", systemImage: "opticaldisc") + } + .tint(.gray) + .buttonStyle(.borderedProminent) + .controlSize(.large) + .conditionalEffect(.smoke, condition: isEnabled) + } + } + .padding() + } + } + + static var previews: some View { + if #available(iOS 16.0, *) { + Preview16() + .preferredColorScheme(.dark) + } else { + Preview() + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Anvil.swift b/Sources/Pow/Transitions/Anvil.swift new file mode 100644 index 0000000..c89a5c2 --- /dev/null +++ b/Sources/Pow/Transitions/Anvil.swift @@ -0,0 +1,285 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition that drops the view down from the top. + /// + /// The transition is only performed on insertion and takes 1.4 seconds. + static var anvil: AnyTransition { + .asymmetric( + insertion: .modifier( + active: Anvil(animatableData: 0), + identity: Anvil(animatableData: 1) + ), + removal: .identity + ) + .animation(.linear(duration: 1.4)) + } +} + +internal struct Anvil: ViewModifier, Animatable, AnimatableModifier { + var animatableData: CGFloat = 0 + + #if os(iOS) + @State + var feedbackGenerator: UIImpactFeedbackGenerator? + #endif + + internal init(animatableData: CGFloat = 0) { + self.animatableData = animatableData + } + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + /// Fraction of the animation spent on the view falling down. + let fall: CGFloat = 0.1 + + /// Progress of the fall. + let fallT = map(value: min(progress, fall), inMin: 0, inMax: fall, outMin: 0, outMax: 1) + + /// Progress of the shake. + let shakeT = map(value: clamp(fall, progress - 0.01, 2 * fall) - fall, inMin: 0, inMax: fall, outMin: 0, outMax: 1) + + let padding = EdgeInsets(top: 150, leading: 130, bottom: 100, trailing: 130) + + let grayImage: Image = Image("anvil_smoke_gray", bundle: .module) + let whiteImage: Image = Image("anvil_smoke_white", bundle: .module) + + content + #if os(iOS) + .onChange(of: fallT) { newFallT in + if fallT < 1 && newFallT >= 1 { + feedbackGenerator?.impactOccurred() + feedbackGenerator = nil + } else if newFallT > 0 && feedbackGenerator == nil { + feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) + feedbackGenerator?.prepare() + } + } + #endif + .offset(x: 0, y: -400 * (1 - fallT)) + .animation(nil, value: progress) + .offset( + x: 2 * sin(shakeT * 3 * .pi) * (1 - shakeT), + y: 4 * sin(shakeT * 4 * .pi) * (1 - shakeT) + ) + .overlay { + Canvas { ctx, size in + if progress == 1 { return } + + var rng = SeededRandomNumberGenerator(seed: size.width) + + let bounds = CGRect(origin: .zero, size: size).insetBy(dx: 130, dy: 100) + + do { + let resolvedGrayImage = ctx.resolve(grayImage) + let resolvedWhiteImage = ctx.resolve(whiteImage) + + /// Progress of the dust animation. + let dustT = map(value: max(0, progress - fall), inMin: 0, inMax: 1 - fall, outMin: 0, outMax: 1) + + // How far are the particles apart. + let particleDistance: CGFloat = 10 + + let particleSize = CGSize(width: 88, height: 88) + + let rows = Int((bounds.width / particleDistance).rounded(.up)) + let cols = 2 + + guard rows > 0 else { + return + } + + for x in 0 ..< rows { + for _ in 0 ..< cols { + let x = CGFloat(x) + let relativeX = (x / CGFloat(rows - 1)) + + let center = CGPoint( + x: bounds.minX + x * particleDistance + .random(in: -15 ... 15, using: &rng), + y: bounds.maxY + .random(in: -5 ... 5, using: &rng) + ) + + let maxOffsetX: CGFloat = particleDistance * 4 + let maxOffsetY: CGFloat = particleDistance * 2 + + let t = easeOut(dustT) + + let offsetX = maxOffsetX * (relativeX - 0.5) * 2 * .random(in: 0.8 ... 1.2, using: &rng) + let offsetY = CGFloat.random(in: -maxOffsetY / 2 ... maxOffsetY / 2, using: &rng) + (t * t) * -50 + + var scale = 1 + 0.6 * (1 - pow(sin(relativeX * .pi), 0.4)) + .random(in: 0 ... 0.2, using: &rng) + scale *= 0.8 + (dustT * 0.2) + scale /= 3 + scale *= 1 - pow(2, -50 * dustT) + + var rotation = Angle.degrees(180) * .random(in: -1 ... 1, using: &rng) + rotation += .degrees(125) * -(relativeX - 0.5) * CGFloat.random(in: 0.5 ... 1, using: &rng) * t * 1.5 + + ctx.drawLayer { ctx in + ctx.translateBy(x: 0, y: -(particleSize.height * scale * 0.9) / 2) + + ctx.translateBy( + x: offsetX * t, + y: offsetY * t + ) + + ctx.translateBy(x: center.x, y: center.y) + ctx.rotate(by: rotation) + + ctx.scaleBy(x: scale, y: scale) + + ctx.opacity = 0.8 * (1 - 0.5 * abs(relativeX - 0.5)) * (1 - dustT) + + if progress >= fall { + if Double(x).truncatingRemainder(dividingBy: 2.0).isZero { + ctx.draw(resolvedWhiteImage, in: CGRect(center: .zero, size: resolvedWhiteImage.size)) + } else { + ctx.draw(resolvedGrayImage, in: CGRect(center: .zero, size: resolvedGrayImage.size)) + } + } + } + } + } + } + + do { + // Progress of the specks animating. + let speckT = clamp(map(value: progress, inMin: fall + 0.02, inMax: 1 - 0.2, outMin: 0, outMax: 1)) + + let specks = 20 + + let speckSize = CGSize(width: 1, height: 1) + + let arc = 1 - pow(2 * speckT - 1, 2) + + let maxOffsetY = bounds.height * 0.9 + let maxOffsetX = bounds.width * 0.6 + + for s in 0 ..< specks { + let s = CGFloat(s) + + let xFrac = (s / CGFloat(specks)) + + var dX = CGFloat.random(in: -maxOffsetX ... maxOffsetX, using: &rng) + dX += 60 * (xFrac - 0.5) * 2 + + let dY = CGFloat.random(in: -maxOffsetY ... 0, using: &rng) + + ctx.drawLayer { ctx in + var center = CGPoint( + x: .random(in: bounds.minX ... bounds.maxX, using: &rng), + y: bounds.maxY + ) + + center.x += dX * speckT + center.y += arc * dY + + let speckBounds = CGRect( + origin: .zero, + size: speckSize + ) + + let speck = Circle().path(in: speckBounds) + + let scale = CGFloat.random(in: 2 ... 3, using: &rng) * (0.5 + (1 - speckT) / 2) + + ctx.translateBy(x: center.x, y: center.y) + ctx.scaleBy(x: scale, y: scale) + + ctx.opacity = Double(pow(sin(speckT * .pi), 0.2)) + ctx.fill(speck, with: .color(Color(white: .random(in: 0.75 ... 0.9, using: &rng)))) + } + } + } + } + .padding(padding.inverse) + .allowsHitTesting(false) + } + } +} + +extension EdgeInsets { + var inverse: Self { + EdgeInsets(top: -top, leading: -leading, bottom: -bottom, trailing: -trailing) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Anvil_Previews: PreviewProvider { + struct Item: Identifiable { + var color: Color + + let id: UUID = UUID() + + init() { + color = [Color.red, .orange, .yellow, .green, .purple, .mint].randomElement()! + } + } + + struct Preview: View { + @State + var items: [Item] = [Item()] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Anvil") + .bold() + + Text("myView.transition(**.movingParts.anvil**)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper("Count") { + withAnimation { + items.append(Item()) + } + } onDecrement: { + if !items.isEmpty { + items.removeLast() + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(items) { item in + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(item.color) + .transition(.movingParts.anvil) + .aspectRatio(1, contentMode: .fit) + .id(item.id) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} +#endif diff --git a/Sources/Pow/Transitions/Blinds.swift b/Sources/Pow/Transitions/Blinds.swift new file mode 100644 index 0000000..cb68bb8 --- /dev/null +++ b/Sources/Pow/Transitions/Blinds.swift @@ -0,0 +1,223 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// The style of blinds to use with a `blinds` transition. + enum BlindsStyle: Sendable { + /// Blinds with slats that cover the width of the view. + case venetian + /// Blinds with slats that cover the height of the view. + case vertical + } + + /// A transition that reveals the view as if it was behind window blinds. + static var blinds: AnyTransition { + blinds(slatWidth: 10) + } + + /// A transition that reveals the view as if it was behind window blinds. + /// + /// - Parameters: + /// - slatWidth: The width of each slat. + /// - style: The style of blinds. + /// - isStaggered: Whether all slats opens at the same time or in sequence. + static func blinds(slatWidth: CGFloat, style: BlindsStyle = .venetian, isStaggered: Bool = false) -> AnyTransition { + let clampedHeight = clamp(5, slatWidth, .greatestFiniteMagnitude) + + return .modifier( + active: Blinds(slatWidth: clampedHeight, style: style, isStaggered: isStaggered, animatableData: 0), + identity: Blinds(slatWidth: clampedHeight, style: style, isStaggered: isStaggered, animatableData: 1) + ) + } +} + +internal struct Blinds: ViewModifier, Animatable, AnimatableModifier, Hashable { + var slatWidth: CGFloat + + var style: AnyTransition.MovingParts.BlindsStyle + + var isStaggered: Bool + + var animatableData: CGFloat + + private var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + content + .mask { + BlindsShape(slatWidth: slatWidth, style: style, open: progress, isStaggered: isStaggered) + .flipsForRightToLeftLayoutDirection(true) + } + } +} + +private struct BlindsShape: Shape { + var slatWidth: CGFloat + + var style: AnyTransition.MovingParts.BlindsStyle + + var open: Double + + var isStaggered: Bool + + func path(in rect: CGRect) -> Path { + let slatCount: Int + switch style { + case .venetian: + slatCount = Int((rect.height / slatWidth).rounded(.up)) + case .vertical: + slatCount = Int((rect.width / slatWidth).rounded(.up)) + } + + let slatRects = (0 ..< slatCount) + .map { slatIndex -> CGRect in + let progress: Double + if isStaggered { + let fraction = 1.0 - (Double(slatIndex) / Double(slatCount)) + progress = clamp(0.0, (open * 2.0 - 1.0) + fraction, 1.0) + } else { + progress = open + } + + let position = Double(slatIndex) * slatWidth + slatWidth * (1.0 - progress) / 2.0 + + switch style { + case .venetian: + return CGRect( + x: 0, + y: position, + width: rect.width, + height: slatWidth * progress + ) + case .vertical: + return CGRect( + x: position, + y: 0, + width: slatWidth * progress, + height: rect.height + ) + } + } + + return Path { path in + path.addRects(slatRects, transform: CGAffineTransform.identity) + } + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Blinds_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + @State + var slatWidth: CGFloat = 10 + + @State + var blindsStyle: AnyTransition.MovingParts.BlindsStyle = .venetian + + @State + var isStaggered: Bool = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Blinds") + .bold() + + Text(""" + myView.transition( + .movingParts.blinds(slatWidth: 15, isStaggered: true)) + ) + """) + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + if #available(iOS 16.0, *) { + LabeledContent { + Slider(value: $slatWidth, in: 0...50) + } label: { + ZStack { + Text("99").hidden() + Text(slatWidth, format: .number.precision(.fractionLength(0))) + } + .monospacedDigit() + } + } + + if #available(iOS 16.0, *) { + LabeledContent("Style") { + Picker("Picker", selection: $blindsStyle) { + Text("Venetian").tag(AnyTransition.MovingParts.BlindsStyle.venetian) + Text("Vertical").tag(AnyTransition.MovingParts.BlindsStyle.vertical) + } + } + .pickerStyle(.menu) + } + + Toggle("Staggered", isOn: $isStaggered) + + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition(.movingParts.blinds(slatWidth: slatWidth, style: blindsStyle, isStaggered: isStaggered)) + .aspectRatio(2, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Blur.swift b/Sources/Pow/Transitions/Blur.swift new file mode 100644 index 0000000..a19861d --- /dev/null +++ b/Sources/Pow/Transitions/Blur.swift @@ -0,0 +1,130 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition from blurry to sharp on insertion, and from sharp to blurry + /// on removal. + static var blur: AnyTransition { + .modifier( + active: Blur(radius: 30), + identity: Blur(radius: 0) + ) + } + + /// A transition from blurry to sharp on insertion, and from sharp to blurry + /// on removal. + /// + /// - Parameter radius: The radial size of the blur at the end of the transition. + static func blur(radius: CGFloat) -> AnyTransition { + .modifier( + active: Blur(radius: radius), + identity: Blur(radius: 0) + ) + } +} + +internal struct Blur: ViewModifier, Animatable, AnimatableModifier, Hashable { + var animatableData: CGFloat { + get { radius } + set { radius = newValue } + } + + var radius: CGFloat + + func body(content: Content) -> some View { + content + .blur(radius: radius) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Blur_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + @State + var radius: CGFloat = 30 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Flip") + .bold() + + Text(""" + myView.transition( + .transition(.flip) + ) + """) + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + if #available(iOS 16.0, *) { + LabeledContent { + Slider(value: $radius, in: 0.0...100.0) + .frame(width: 150) + } label: { + Text("Radius: \(radius, format: .number.precision(.fractionLength(0)))") + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition(.movingParts.blur(radius: radius).combined(with: .opacity)) + .aspectRatio(2, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Boing.swift b/Sources/Pow/Transitions/Boing.swift new file mode 100644 index 0000000..f72318c --- /dev/null +++ b/Sources/Pow/Transitions/Boing.swift @@ -0,0 +1,301 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition that moves the view down with any overshoot resulting in an + /// elastic deformation of the view. + static var boing: AnyTransition { + boing(edge: .top) + } + + /// A transition that moves the view from the specified edge on insertion, + /// and towards it on removal, with any overshoot resulting in an elastic + /// deformation of the view. + static func boing(edge: Edge) -> AnyTransition { + .modifier( + active: Scaled(Boing(edge, animatableData: 0)), + identity: Scaled(Boing(edge, animatableData: 1)) + ) + } +} + +internal struct Boing: Animatable, GeometryEffect { + var edge: Edge + + var animatableData: CGFloat = 0 + + internal init(_ edge: Edge, animatableData: CGFloat = 0) { + self.animatableData = animatableData + self.edge = edge + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let area = size.width * size.height + + var mainAxisSize: CGFloat { + edge == .leading || edge == .trailing ? size.width : size.height + } + + var crossAxisSize: CGFloat { + edge == .leading || edge == .trailing ? size.height : size.width + } + + let deltaP = -mainAxisSize * 2 * (1 - animatableData) + + var t = CGAffineTransform.identity + + if deltaP < 1 { + let newMainAxisSize = rubberClamp(mainAxisSize / 2, mainAxisSize - deltaP / 3, mainAxisSize * 1.5) + let newCrossAxisSize = area / newMainAxisSize + + t = t.translatedBy(x: size.width / 2, y: size.height / 2) + + switch edge { + case .top: + t = t.translatedBy(x: 0, y: deltaP) + case .bottom: + t = t.translatedBy(x: 0, y: -deltaP) + case .leading: + t = t.translatedBy(x: deltaP, y: 0) + case .trailing: + t = t.translatedBy(x: -deltaP, y: 0) + } + + if edge == .leading || edge == .trailing { + t = t.scaledBy(x: newMainAxisSize / mainAxisSize, y: newCrossAxisSize / crossAxisSize) + } else { + t = t.scaledBy(x: newCrossAxisSize / crossAxisSize, y: newMainAxisSize / mainAxisSize) + } + + t = t.translatedBy(x: -size.width / 2, y: -size.height / 2) + } + + if deltaP >= 5 { + let deltaY = deltaP - 5 + + let newMainAxisSize = rubberClamp(mainAxisSize * 0.75, mainAxisSize - deltaY / 3, mainAxisSize * 1) + let newCrossAxisSize = area / newMainAxisSize + + let translation: CGAffineTransform + + switch edge { + case .top: + translation = CGAffineTransformMakeTranslation(size.width / 2, size.height) + case .leading: + translation = CGAffineTransformMakeTranslation(size.width, size.height / 2) + case .bottom: + translation = CGAffineTransformMakeTranslation(size.width / 2, 0) + case .trailing: + translation = CGAffineTransformMakeTranslation(0, size.height / 2) + } + + t = translation.concatenating(t) + + if edge == .leading || edge == .trailing { + t = t.scaledBy(x: newMainAxisSize / mainAxisSize, y: newCrossAxisSize / crossAxisSize) + } else { + t = t.scaledBy(x: newCrossAxisSize / crossAxisSize, y: newMainAxisSize / mainAxisSize) + } + + t = translation.inverted().concatenating(t) + } + + return ProjectionTransform(t) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Bounce_Previews: PreviewProvider { + struct Item: Identifiable { + var color: Color + + let id: UUID = UUID() + + init() { + color = [Color.red, .orange, .yellow, .green, .indigo, .teal].randomElement()! + } + } + + struct Preview: View { + @State + var items: [Item] = [Item()] + + @State + var damping: Double = 0.5 + + @State + var edge: Edge = .top + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Boing") + .bold() + + Text("myView.transition(**.movingParts.boing**)\n .animation(.interactiveSpring(\n dampingFraction: \(damping.formatted(.number.precision(.fractionLength(2))))\n )\n)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Slider(value: $damping, in: 0.2 ... 0.8) + + Stepper("Count") { + withAnimation { + var item = Item() + item.color = [Color.red, .orange, .yellow, .green, .indigo, .teal].shuffled().first { color in + !items.contains { $0.color == color } + } ?? .blue + + items.append(item) + } + } onDecrement: { + if !items.isEmpty { + items.removeLast() + } + } + + if #available(iOS 16.0, *) { + LabeledContent("Edge") { + Picker("Edge", selection: $edge) { + Group { + Label("Leading", systemImage: "arrow.forward").tag(Edge.leading) + Label("Trailing", systemImage: "arrow.backward").tag(Edge.trailing) + Label("Top", systemImage: "arrow.down").tag(Edge.top) + Label("Bottom", systemImage: "arrow.up").tag(Edge.bottom) + } + } + } + .pickerStyle(.menu) + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(items) { item in + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(item.color) + .overlay { + Text("Jell-O\nWorld") + .blendMode(.difference) + .offset(x: 2, y: 2) + } + .compositingGroup() + .overlay { + Text("Jell-O\nWorld") + } + .font(.system(.headline, design: .rounded).weight(.black)) + .multilineTextAlignment(.center) + .transition( + .movingParts.boing(edge: edge) + .animation(.spring(dampingFraction: damping)) + .combined(with: .opacity.animation(.easeOut(duration: 0.01))) + ) + .aspectRatio(1, contentMode: .fit) + .id(item.id) + } + } + + Spacer() + } + .padding(.horizontal) + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} + +@available(iOS 15.0, *) +struct Boing_2_Previews: PreviewProvider { + struct Preview: View { + @State + var isVisible: Bool = false + + @State + var isRightToLeft: Bool = true + + var body: some View { + VStack { + Toggle("Visible", isOn: $isVisible.animation()) + + Toggle("Right To Left", isOn: $isRightToLeft) + + if #available(iOS 16.0, *) { + LabeledContent("Reference") { + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + } + } else { + HStack { + Text("Reference") + Spacer() + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + } + } + + Spacer() + + let overshoot = Animation.movingParts.overshoot(duration: 0.3) + let mediumSpring = Animation.interactiveSpring(dampingFraction: 0.5) + let looseSpring = Animation.interpolatingSpring(stiffness: 100, damping: 8) + + Group { + if isVisible { + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.boing(edge: .leading).animation(overshoot)) + + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.boing(edge: .leading).animation(mediumSpring)) + + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.boing(edge: .trailing).animation(looseSpring)) + + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.move(edge: .leading).animation(looseSpring)) + } + } + + Spacer() + } + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + .padding() + .background { + Color.white.ignoresSafeArea() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + } + } +} +#endif + +private extension CGAffineTransform { + init(skewX x: CGFloat, y: CGFloat) { + self.init(a: 1, b: x, c: y, d: 1, tx: 0, ty: 0) + } +} diff --git a/Sources/Pow/Transitions/Clock.swift b/Sources/Pow/Transitions/Clock.swift new file mode 100644 index 0000000..c8108a2 --- /dev/null +++ b/Sources/Pow/Transitions/Clock.swift @@ -0,0 +1,296 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition using a clockwise sweep around the centerpoint of the view. + static var clock: AnyTransition { + clock(blurRadius: 0) + } + + /// A transition using a clockwise sweep around a point in the view. + /// + /// - Parameter origin: The centerpoint of the sweep. + /// - Parameter blurRadius: The radius of the blur applied to the mask. + static func clock(origin: UnitPoint = .center, blurRadius: CGFloat) -> AnyTransition { + .modifier( + active: Clock(origin: origin, blurRadius: blurRadius, progress: 0), + identity: Clock(origin: origin, blurRadius: blurRadius, progress: 1) + ) + } +} + +internal struct Clock: ViewModifier, Animatable, AnimatableModifier { + var origin: UnitPoint + + var animatableData: AnimatablePair + + init(origin: UnitPoint, blurRadius: CGFloat, progress: CGFloat) { + self.origin = origin + self.animatableData = AnimatableData(progress, blurRadius) + } + + var progress: CGFloat { + animatableData.first + } + + var blurRadius: CGFloat { + animatableData.second + } + + @Environment(\.layoutDirection) + var layoutDirection + + func body(content: Content) -> some View { + let blurProgress = 1 - pow(2, 15 * (progress - 1)) + + content + .mask( + Circle(unitPoint: origin, layoutDirection: layoutDirection) + .trim(from: 0, to: clamp(progress * 1.05)) + .padding(-blurRadius * blurProgress) + .blur(radius: blurRadius * blurProgress) + ) + } + + private struct Circle: Shape { + var unitPoint: UnitPoint + + var layoutDirection: LayoutDirection + + func path(in rect: CGRect) -> Path { + let origin = CGPoint( + x: layoutDirection == .rightToLeft + ? rect.maxX - rect.width * unitPoint.x + : rect.minX + rect.width * unitPoint.x, + y: rect.minY + rect.height * unitPoint.y + ) + + let (startAngle, endAngle) = rect.clockStartAndEndAngles(for: origin) + + return Path { path in + path.move(to: origin) + path.addArc( + center: origin, + radius: rect.diagonal / 2 + origin.distance(to: rect.center), + startAngle: startAngle, + endAngle: endAngle, + clockwise: false + ) + } + } + } +} + +#if os(iOS) && DEBUG +struct Clock_Previews: PreviewProvider { + struct Item: Identifiable { + var color: Color + + let id: UUID = UUID() + + init() { + color = [Color.red, .orange, .yellow, .green, .purple, .mint].randomElement()! + } + } + + struct Preview: View { + @State + var items: [Item] = [Item()] + + enum ShapeType: String, Hashable, Identifiable, CaseIterable { + case rectangle = "Rectangle" + case roundedRectangle = "Rounded Rectangle" + case capsule = "Capsule" + case circle = "Circle" + + var name: String { + return rawValue + } + + var id: Self { + return self + } + + var symbolName: String { + switch self { + case .rectangle: + return "rectangle.fill" + case .roundedRectangle: + return "rectangle.roundedtop.fill" + case .capsule: + return "capsule.fill" + case .circle: + return "circle.fill" + } + } + } + + @State + var selectedShape: ShapeType = .roundedRectangle + + @ViewBuilder + func filledShape(color: Color) -> some View { + switch selectedShape { + case .rectangle: + Rectangle().fill(color) + case .roundedRectangle: + RoundedRectangle(cornerRadius: 8, style: .continuous).fill(color) + case .capsule: + Capsule().fill(color) + case .circle: + Circle().fill(color) + } + } + + @State + var originX: CGFloat = 0.5 + + @State + var originY: CGFloat = 0.5 + + @State + var blurRadius: CGFloat = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Clock Wipe") + .bold() + + Text("myView.transition(**.movingParts.clock**)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper("Count") { + withAnimation { + items.append(Item()) + } + } onDecrement: { + withAnimation { + if !items.isEmpty { + items.removeLast() + } + } + } + + if #available(iOS 16.0, *) { + LabeledContent("Shape") { + Picker("Shape", selection: $selectedShape) { + ForEach(ShapeType.allCases) { shapeType in + Label(shapeType.name, systemImage: shapeType.symbolName).tag(shapeType) + } + } + } + .pickerStyle(.menu) + + LabeledContent("Origin") { + Text(originX, format: .number.precision(.fractionLength(2))) + + Text("×") + + Text(originY, format: .number.precision(.fractionLength(2))) + } + Slider(value: $originX, in: -0.5...1.5) + Slider(value: $originY, in: -0.5...1.5) + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(items) { item in + filledShape(color: item.color) + .transition(.movingParts.clock(origin: .init(x: originX, y: originY), blurRadius: 10)) + .aspectRatio(1/1.4, contentMode: .fit) + .id(item.id) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} +#endif + +private extension CGRect { + func clockStartAndEndAngles(for point: CGPoint) -> (start: Angle, end: Angle) { + if point.x <= minX { + if point.y <= minY { + // topLeft + return ( + start: point.angle(to: topRight), + end: point.angle(to: bottomLeft) + ) + } else if point.y >= maxY { + // bottomLeft + return ( + start: point.angle(to: topLeft), + end: point.angle(to: bottomRight) + ) + } else { + // left + return ( + start: point.angle(to: topLeft), + end: point.angle(to: bottomLeft) + ) + } + } else if point.x >= maxX { + if point.y <= 0.0 { + // topRight + return ( + start: point.angle(to: bottomRight), + end: point.angle(to: topLeft) + ) + } else if point.y >= maxY { + // bottomRight + return ( + start: point.angle(to: bottomLeft), + end: point.angle(to: topRight) + ) + } else { + // right + return ( + start: point.angle(to: bottomRight), + end: point.angle(to: topRight) + ) + } + } else { + if point.y <= minY { + // top + return ( + start: point.angle(to: topRight), + end: point.angle(to: topLeft) + ) + } else if point.y >= maxY { + // bottom + return ( + start: point.angle(to: bottomLeft), + end: point.angle(to: bottomRight) + ) + } else { + // center + return ( + start: .degrees(0) - .degrees(90), + end: .degrees(360) - .degrees(90) + ) + } + } + } +} diff --git a/Sources/Pow/Transitions/FilmExposure.swift b/Sources/Pow/Transitions/FilmExposure.swift new file mode 100644 index 0000000..bb1e3e3 --- /dev/null +++ b/Sources/Pow/Transitions/FilmExposure.swift @@ -0,0 +1,136 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition from completely dark to fully visible on insertion, and + /// from fully visible to completely dark on removal. + static var filmExposure: AnyTransition { + .modifier( + active: ExposureFade(animatableData: 0), + identity: ExposureFade(animatableData: 1) + ) + } + + /// A transition from completely bright to fully visible on insertion, and + /// from fully visible to completely bright on removal. + static var snapshot: AnyTransition { + .modifier( + active: Snapshot(animatableData: 0), + identity: Snapshot(animatableData: 1) + ) + } +} + +internal struct Snapshot: ViewModifier, Animatable, AnimatableModifier, Hashable { + var animatableData: CGFloat = 0 + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + content + .saturation(0.5 + 0.5 * clamp(progress)) + .contrast(0.5 + 0.5 * clamp(progress)) + .brightness(0.85 * (1.0 - clamp(1 * progress))) + .blur(radius: 5.0 * (1.0 - clamp(progress)), opaque: true) + .animation(nil, value: progress) + } +} + +internal struct ExposureFade: ViewModifier, Animatable, AnimatableModifier, Hashable { + var animatableData: CGFloat = 0 + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + content + .opacity(Double(1.0 - pow(2.0, -10.0 * progress))) + .brightness(-1.0 * (1.0 - clamp(progress))) + .animation(nil, value: progress) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct ExoposureFade_Previews: PreviewProvider { + struct Preview: View { + @State + var url: URL = URL(string: "https://picsum.photos/500")! + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Snapshot") + .bold() + + Text("myView.transition(\n .movingParts.snapshot\n)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + AsyncImage(url: url, transaction: Transaction(animation: .easeInOut(duration: 1.8))) { phase in + ZStack { + Color.black + + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + .id(UUID()) + .transition(.movingParts.snapshot) + case .failure(let error): + Text(error.localizedDescription) + .font(.caption) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + .environment(\.colorScheme, .dark) + .aspectRatio(1, contentMode: .fit) + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Button { + url = [ + URL(string: "https://picsum.photos/400")!, + URL(string: "https://picsum.photos/420")!, + URL(string: "https://picsum.photos/440")!, + URL(string: "https://picsum.photos/480")!, + ] + .filter { $0 != url } + .randomElement() ?? url + } label: { + Label("Shuffle", systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} +#endif diff --git a/Sources/Pow/Transitions/Flicker.swift b/Sources/Pow/Transitions/Flicker.swift new file mode 100644 index 0000000..e09ac44 --- /dev/null +++ b/Sources/Pow/Transitions/Flicker.swift @@ -0,0 +1,129 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition that toggles the visibility of the view multiple times + /// before settling. + static var flicker: AnyTransition { + flicker(count: 1) + } + + /// A transition that toggles the visibility of the view multiple times + /// before settling. + /// + /// - Parameter count: The number of times the visibility is toggled. + static func flicker(count: Int) -> AnyTransition { + let count = clamp(1, count, .max) + + return .modifier( + active: Flicker(count: count, animatableData: 0), + identity: Flicker(count: count, animatableData: 1) + ) + } +} + +internal struct Flicker: ViewModifier, Animatable, AnimatableModifier, Hashable { + var count: Int + + var animatableData: CGFloat + + private var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + private var isVisible: Bool { + (progress * CGFloat(count)).remainder(dividingBy: 1) >= 0 + } + + func body(content: Content) -> some View { + content + .opacity(isVisible ? 1 : 0) + .animation(nil, value: isVisible) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Flicker_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + @State + var count: Int = 2 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Flicker") + .bold() + + Text(""" + myView.transition( + .transition(.flicker(count: 2)) + ) + """) + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + Stepper("Flicker Count \(count)", value: $count, in: 1...99) + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition(.movingParts.flicker(count: count)) + .aspectRatio(2, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Flip.swift b/Sources/Pow/Transitions/Flip.swift new file mode 100644 index 0000000..808932f --- /dev/null +++ b/Sources/Pow/Transitions/Flip.swift @@ -0,0 +1,154 @@ +import SwiftUI +import simd + +public extension AnyTransition.MovingParts { + /// A transition that inserts by rotating the view towards the viewer, and + /// removes by rotating the view away from the viewer. + /// + /// - Note: Any overshoot of the animation will result in the view + /// continuing the rotation past the view's normal state before + /// eventually settling. + static var flip: AnyTransition { + .modifier( + active: Transform3DEffect(rotation: simd_quatd(angle: Angle(degrees: 90).radians, axis: [1, 0, 0]), perspective: 1 / 6).shaded, + identity: Transform3DEffect(perspective: 1 / 6).shaded + ) + } + + /// A transition that inserts by rotating from the specified rotation, and + /// removes by rotating to the specified rotation in three dimensions. + /// + /// In this example, the view is rotated 90˚ about the y axis around + /// its bottom edge as if it was rising from lying on its back face: + /// + /// ```swift + /// Text("Hello") + /// .transition(.movingParts.rotate3D( + /// .degrees(90), + /// axis: (1, 0, 0), + /// anchor: .bottom, + /// perspective: 1.0 / 6.0) + /// ) + /// ``` + /// + /// - Note: Any overshoot of the animation will result in the view + /// continuing the rotation past the view's normal state before + /// eventually settling. + /// + /// - Parameters: + /// - angle: The angle from which to rotate the view. + /// - axis: The x, y and z elements that specify the axis of rotation. + /// - anchor: The location with a default of center that defines a point + /// in 3D space about which the rotation is anchored. + /// - anchorZ: The location with a default of 0 that defines a point in 3D + /// space about which the rotation is anchored. + /// - perspective: The relative vanishing point with a default of 1 for + /// this rotation. + static func rotate3D(_ angle: Angle, axis: (x: CGFloat, y: CGFloat, z: CGFloat), anchor: UnitPoint = .center, anchorZ: CGFloat = 0, perspective: CGFloat = 1) -> AnyTransition { + let active = Transform3DEffect( + rotation: simd_quatd(angle: angle.radians, axis: [axis.x, axis.y, axis.z]), + anchor: anchor, + anchorZ: anchorZ, + perspective: perspective + ) + + let identity = Transform3DEffect( + anchor: anchor, + anchorZ: anchorZ, + perspective: perspective + ) + + return .modifier( + active: active.shaded, + identity: identity.shaded + ) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Flip_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Flip") + .bold() + + Text(""" + myView.transition( + .transition(.flip) + ) + """) + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + let animation = Animation.interpolatingSpring( + mass: 1, + stiffness: 10, + damping: 10, + initialVelocity: 10 + ) + + withAnimation(animation) { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation(.easeInOut) { + indices.removeLast() + } + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition(.movingParts.flip) + .aspectRatio(1, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Glare.swift b/Sources/Pow/Transitions/Glare.swift new file mode 100644 index 0000000..508f29a --- /dev/null +++ b/Sources/Pow/Transitions/Glare.swift @@ -0,0 +1,259 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transitions that shows the view by combining a diagonal wipe with a + /// white streak. + static var glare: AnyTransition { + glare(angle: .degrees(45)) + } + + /// A transitions that shows the view by combining a wipe with a colored + /// streak. + /// + /// The angle is relative to the current `layoutDirection`, such that 0° + /// represents sweeping towards the trailing edge on insertion and 90° + /// represents sweeping towards the bottom edge. + /// + /// In this example, the removal of the view is using a glare with an + /// exponential ease-in curve, combined with a anticipating scale animation, + /// making for a more dramatic exit. + /// + /// ```swift + /// infoBox + /// .transition( + /// .asymmetric( + /// insertion: .movingParts.glare(angle: .degrees(225)), + /// removal: .movingParts.glare(angle: .degrees(45)) + /// .animation(.movingParts.easeInExponential(duration: 0.9)) + /// .combined(with: + /// .scale(scale: 1.4).animation(.movingParts.anticipate(duration: 0.9).delay(0.1)) + /// ) + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - direction: The angle of the wipe. + /// - color: The color of the glare effect. + /// - increasedBrightness: A Boolean that indicates whether the glare is displayed with increased brightness. Defaults to `true`. + static func glare(angle: Angle, color: Color = .white, increasedBrightness: Bool = true) -> AnyTransition { + .modifier( + active: Glare(angle, color: color, increasedBrightness: increasedBrightness, animatableData: 0), + identity: Glare(angle, color: color, increasedBrightness: increasedBrightness, animatableData: 1) + ) + } +} + +internal struct Glare: ViewModifier, Animatable, AnimatableModifier { + var animatableData: CGFloat = 0 + + var angle: Angle + + var color: Color + + var increasedBrightness: Bool + + @Environment(\.layoutDirection) + var layoutDirection + + internal init(_ angle: Angle, color: Color, increasedBrightness: Bool = true, animatableData: CGFloat = 0) { + self.animatableData = animatableData + self.angle = angle + self.color = color + self.increasedBrightness = increasedBrightness + } + + func body(content: Content) -> some View { + let l = animatableData * 1.6 + let t = animatableData * 1 + + let full = color + let empty = color.opacity(0) + + content + .mask { + GeometryReader { p in + let bounds = CGRect(origin: .zero, size: p.size).boundingBox(at: angle) + + Rectangle() + .fill( + LinearGradient( + stops: [ + Gradient.Stop(color: .black, location: -1), + Gradient.Stop(color: .black, location: l), + Gradient.Stop(color: .clear, location: l), + Gradient.Stop(color: .clear, location: 2), + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: bounds.width, height: bounds.height) + .position(x: bounds.midX, y: bounds.midY) + .rotationEffect(angle) + .animation(nil, value: animatableData) + .animation(nil, value: angle) + } + } + .overlay { + GeometryReader { p in + let bounds = CGRect(origin: .zero, size: p.size).boundingBox(at: angle) + + Rectangle() + .fill( + LinearGradient( + stops: [ + Gradient.Stop(color: empty, location: -1), + Gradient.Stop(color: empty, location: t), + Gradient.Stop(color: full, location: t + 0.01), + Gradient.Stop(color: full, location: l + 0.01), + Gradient.Stop(color: empty, location: l + 0.02), + Gradient.Stop(color: empty, location: 2), + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: bounds.width, height: bounds.height) + .position(x: bounds.midX, y: bounds.midY) + .rotationEffect(angle) + .brightness(increasedBrightness ? 4 * easeInCubic(clamp(1.0 - animatableData)) : 0) + .blendMode(.sourceAtop) + .allowsHitTesting(false) + .animation(nil, value: animatableData) + .animation(nil, value: angle) + } + } + .compositingGroup() + } +} + +#if os(iOS) && DEBUG +@available(iOS 16.0, *) +struct Glare_Previews: PreviewProvider { + struct Item: Identifiable { + var color1: Color + var color2: Color + + let id: UUID = UUID() + + init() { + let color1: Color = [.indigo, .purple, .pink].randomElement()! + + self.color1 = color1 + self.color2 = [.indigo, .purple, .pink].filter { + $0 != color1 + }.randomElement()! + } + } + + struct Preview: View { + @State + var items: [Item] = [Item()] + + @State + var angle: Angle = .degrees(45) + + @State + var isRightToLeft: Bool = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Glare") + .bold() + + Text("myView.transition(**.movingParts.glare**)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + (Text("View Count ") + Text("(\(items.count))").foregroundColor(.secondary)) + .animation(nil, value: items.count) + } onIncrement: { + withAnimation { + items.append(Item()) + } + } onDecrement: { + withAnimation { + if !items.isEmpty { + items.removeLast() + } + } + } + + Toggle("Right To Left", isOn: $isRightToLeft) + + LabeledContent("Angle") { + AngleControl(angle: $angle) + } + + LabeledContent("Reference") { + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + .rotationEffect(angle) + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()), + ] + + LazyVGrid(columns: columns) { + ForEach(items.indices, id: \.self) { index in + let item = items[index] + + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(LinearGradient( + colors: [item.color1, item.color2], + startPoint: .topLeading, + endPoint: .bottom + )) + .compositingGroup() + .overlay { + Text("Hello\nWorld") + .foregroundStyle(.white.shadow(.inner(radius: 0.5))) + } + .font(.system(.largeTitle, design: .rounded).weight(.medium)) + .multilineTextAlignment(.center) + .transition( + .asymmetric( + insertion: .movingParts.glare(angle: angle), + removal: .movingParts.glare(angle: angle) + .animation(.movingParts.easeInExponential(duration: 0.9)) + .combined(with: + .scale(scale: 1.4).animation(.movingParts.anticipate(duration: 0.9).delay(0.1)) + ) + ) + ) + .aspectRatio(1, contentMode: .fit) + .id(item.id) + .zIndex(Double(index)) + } + } + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + + Spacer() + } + .padding(.horizontal) + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} +#endif diff --git a/Sources/Pow/Transitions/Iris.swift b/Sources/Pow/Transitions/Iris.swift new file mode 100644 index 0000000..7c069c3 --- /dev/null +++ b/Sources/Pow/Transitions/Iris.swift @@ -0,0 +1,213 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition that takes the shape of a growing circle when inserting, + /// and a shrinking circle when removing. + /// + /// - Parameters: + /// - origin: The center point of the circle as it grows or shrinks. + /// - blurRadius: The radius of the blur applied to the mask. + static func iris(origin: UnitPoint = .center, blurRadius: CGFloat = 0) -> AnyTransition { + .modifier( + active: Iris(origin: origin, blurRadius: blurRadius, animatableData: 0), + identity: Iris(origin: origin, blurRadius: blurRadius, animatableData: 1) + ) + } +} + +private struct Iris: ViewModifier, Animatable, AnimatableModifier { + var origin: UnitPoint + + var blurRadius: CGFloat + + var animatableData: CGFloat = 0 + + internal init(origin: UnitPoint, blurRadius: CGFloat = 0, animatableData: CGFloat) { + self.origin = origin + self.blurRadius = clamp(0, blurRadius, 30) + self.animatableData = animatableData + } + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + content + .mask( + GeometryReader { proxy in + let width = proxy.size.width + let height = proxy.size.height + + let scaledWidth = width * 2 * max(origin.x, 1 - origin.x) + let scaledHeight = height * 2 * max(origin.y, 1 - origin.y) + + let diagonal = progress * sqrt(scaledWidth * scaledWidth + scaledHeight * scaledHeight) + + Circle() + .frame(width: diagonal, height: diagonal) + .position( + x: origin.x * width, + y: origin.y * height + ) + .blur(radius: (1 - progress) * blurRadius) + } + ) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Mask_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + enum ShapeType: String, Hashable, Identifiable, CaseIterable { + case rectangle = "Rectangle" + case roundedRectangle = "Rounded Rectangle" + case capsule = "Capsule" + case circle = "Circle" + + var name: String { + return rawValue + } + + var id: Self { + return self + } + + var symbolName: String { + switch self { + case .rectangle: + return "rectangle.fill" + case .roundedRectangle: + return "rectangle.roundedtop.fill" + case .capsule: + return "capsule.fill" + case .circle: + return "circle.fill" + } + } + } + + @State + var selectedShape: ShapeType = .roundedRectangle + + @ViewBuilder + func filledShape(color: Color) -> some View { + switch selectedShape { + case .rectangle: + Rectangle().fill(color) + case .roundedRectangle: + RoundedRectangle(cornerRadius: 8, style: .continuous).fill(color) + case .capsule: + Capsule().fill(color) + case .circle: + Circle().fill(color) + } + } + + @State + var originX: CGFloat = 0.5 + + @State + var originY: CGFloat = 0.5 + + @State + var blurRadius: CGFloat = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Flip") + .bold() + + Text("myView.transition(.movingParts.iris())") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + if #available(iOS 16.0, *) { + LabeledContent("Shape") { + Picker("Shape", selection: $selectedShape) { + ForEach(ShapeType.allCases) { shapeType in + Label(shapeType.name, systemImage: shapeType.symbolName).tag(shapeType) + } + } + } + .pickerStyle(.menu) + + LabeledContent("Origin") { + Text(originX, format: .number.precision(.fractionLength(2))) + + Text("×") + + Text(originY, format: .number.precision(.fractionLength(2))) + } + Slider(value: $originX, in: -0.5...1.5) + Slider(value: $originY, in: -0.5...1.5) + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition( + .asymmetric( + insertion: .movingParts.iris(origin: .init(x: originX, y: originY), blurRadius: blurRadius), + removal: .movingParts.iris(origin: .init(x: originX, y: originY), blurRadius: blurRadius) + ) + ) + .aspectRatio(2, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Move.swift b/Sources/Pow/Transitions/Move.swift new file mode 100644 index 0000000..436aec2 --- /dev/null +++ b/Sources/Pow/Transitions/Move.swift @@ -0,0 +1,263 @@ +import SwiftUI +import simd + +public extension AnyTransition.MovingParts { + /// A transition that moves the view from the specified edge of the on + /// insertion and towards it on removal. + static func move(edge: Edge) -> AnyTransition { + return .modifier( + active: Scaled(Move(edge: edge)), + identity: Scaled(Move()) + ) + } + + /// A transition that moves the view at the specified angle. + /// + /// The angle is relative to the current `layoutDirection`, such that 0° represents animating towards the trailing edge on insertion and 90° represents inserting towards the bottom edge. + /// + /// In this example, the view insertion is animated by moving it towards the top trailing corner and the removal is animated by moving it towards the bottom edge. + /// + /// ```swift + /// Text("Hello") + /// .transition( + /// .asymmetric( + /// insertion: .movingParts.move(angle: .degrees(45)), + /// removal: .movingParts.move(angle: .degrees(90)) + /// ) + /// ) + /// ``` + /// + /// - Parameter angle: The direction of the animation. + static func move(angle: Angle) -> AnyTransition { + return .modifier( + active: Scaled(Move(angle: angle)), + identity: Scaled(Move()) + ) + } +} + +internal struct Move: GeometryEffect, Animatable { + /// Translation is relative, depth is ignored, anchor is always + /// `UnitPoint(0.5, 0.5)`. + var animatableData: TRS = .identity + + init(edge: Edge) { + switch edge { + case .top: + animatableData.translation.y = -1 + case .leading: + animatableData.translation.x = -1 + case .bottom: + animatableData.translation.y = 1 + case .trailing: + animatableData.translation.x = 1 + } + } + + init() {} + + init(angle: Angle) { + let u = cos(angle.radians) + let v = sin(angle.radians) + + let u_2: Double = pow(u, 2) + let v_2: Double = pow(v, 2) + let sq2: Double = sqrt(2.0) + + let x: Double = 0.5 * sqrt(abs(2.0 + u_2 - v_2 + 2.0 * u * sq2)) - 0.5 * sqrt(abs(2.0 + u_2 - v_2 - 2.0 * u * sq2)) + let y: Double = 0.5 * sqrt(abs(2.0 - u_2 + v_2 + 2.0 * v * sq2)) - 0.5 * sqrt(abs(2.0 - u_2 + v_2 - 2.0 * v * sq2)) + + animatableData.translation.x = -x + animatableData.translation.y = -y + } + + private var trs: TRS { + get { animatableData } + set { animatableData = newValue } + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let anchor = UnitPoint.center + + let offset = simd_double4x4(translationX: size.width * anchor.x, y: size.height * anchor.y) + + let translation = simd_double4x4(translationX: trs.translation.x * size.width, y: trs.translation.y * size.height, z: 0) + + let rotation = simd_double4x4(trs.rotation.normalized) + + let scale = simd_double4x4(scaleX: trs.scale.x, y: trs.scale.y, z: 1) + + return ProjectionTransform((((offset * translation) * rotation) * scale) * offset.inverse) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Move_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + enum DirectionType: String, Hashable, Identifiable, CaseIterable { + case edge = "Edge" + case angle = "Angle" + + var name: String { + return rawValue + } + + var id: Self { + return self + } + } + + @State + var directionType: DirectionType = .edge + + @State + var edge: Edge = .leading + + @State + var angle: Angle = .degrees(0) + + @State + var isRightToLeft: Bool = false + + func makeTransition() -> AnyTransition { + switch directionType { + case .edge: + return .movingParts.move(edge: edge) + case .angle: + return .movingParts.move(angle: angle) + } + } + + var resolvedAngle: Angle { + switch directionType { + case .edge: + switch edge { + case .top: + return .degrees(90) + case .leading: + return .degrees(0) + case .bottom: + return .degrees(270) + case .trailing: + return .degrees(180) + } + case .angle: + return angle + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Swoosh") + .bold() + + Text("myView.transition(**.movingParts.move**)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation(.spring()) { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + Toggle("Right To Left", isOn: $isRightToLeft) + + if #available(iOS 16.0, *) { + Picker("Type", selection: $directionType) { + ForEach(DirectionType.allCases) { type in + Text(type.name).tag(type) + } + } + .pickerStyle(.segmented) + + switch directionType { + case .edge: + LabeledContent("Edge") { + Picker("Edge", selection: $edge) { + Group { + Text("Leading").tag(Edge.leading) + Text("Trailing").tag(Edge.trailing) + Text("Top").tag(Edge.top) + Text("Bottom").tag(Edge.bottom) + } + } + } + .pickerStyle(.menu) + .frame(height: 44) + case .angle: + LabeledContent("Angle") { + AngleControl(angle: $angle) + } + .frame(height: 44) + } + + LabeledContent("Reference") { + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + .rotationEffect(resolvedAngle) + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition( + makeTransition().combined(with: .opacity) + ) + .aspectRatio(1.1, contentMode: .fit) + .id(uuid) + } + } + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Poof.swift b/Sources/Pow/Transitions/Poof.swift new file mode 100644 index 0000000..8744489 --- /dev/null +++ b/Sources/Pow/Transitions/Poof.swift @@ -0,0 +1,135 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition that removes the view in a dissolving cartoon style cloud. + /// + /// The transition is only performed on removal and takes 0.4 seconds. + static var poof: AnyTransition { + .asymmetric( + insertion: .identity, + removal: .modifier( + active: Poof(animatableData: 0), + identity: Poof(animatableData: 1) + ) + .animation(.linear(duration: 0.4)) + ) + } +} + +private struct Poof: ViewModifier, Animatable, AnimatableModifier { + var animatableData: CGFloat = 0 + + internal init(animatableData: CGFloat) { + self.animatableData = animatableData + } + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + let frame = (6 * progress).rounded() + + content + .opacity(progress != 1 ? 0 : 1) + .overlay( + ZStack { + poof("poof1").opacity(frame == 5 ? 1 : 0) + poof("poof2").opacity(frame == 4 ? 1 : 0) + poof("poof3").opacity(frame == 3 ? 1 : 0) + poof("poof4").opacity(frame == 2 ? 1 : 0) + poof("poof5").opacity(frame == 1 ? 1 : 0) + + } + .accessibilityHidden(true) + ) + .animation(nil, value: progress) + } + + func poof(_ name: String) -> some View { + Image(name, bundle: .module) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 88, height: 88) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Poof_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Poof") + .bold() + + Text("myView.transition(.movingParts.poof)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition( + .movingParts.poof + ) + .aspectRatio(2, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Pop.swift b/Sources/Pow/Transitions/Pop.swift new file mode 100644 index 0000000..0a7c42b --- /dev/null +++ b/Sources/Pow/Transitions/Pop.swift @@ -0,0 +1,330 @@ +import SwiftUI +import simd + +public extension AnyTransition.MovingParts { + /// A transition that shows a view with a ripple effect and a flurry of + /// tint-colored particles. + /// + /// The transition is only performed on insertion and takes 1.2 seconds. + static var pop: AnyTransition { + pop(.tint) + } + + /// A transition that shows a view with a ripple effect and a flurry of + /// colored particles. + /// + /// In this example, the star uses the pop effect only when transitioning + /// from `starred == false` to `starred == true`: + /// + /// ```swift + /// Button { + /// starred.toggle() + /// } label: { + /// if starred { + /// Image(systemName: "star.fill") + /// .foregroundStyle(.orange) + /// .transition(.movingParts.pop(.orange)) + /// } else { + /// Image(systemName: "star") + /// .foregroundStyle(.gray) + /// .transition(.identity) + /// } + /// } + /// ``` + /// + /// The transition is only performed on insertion. + /// + /// - Parameter style: The style to use for the effect. + static func pop(_ style: S) -> AnyTransition { + let pop = AnyTransition + .modifier( + active: Pop(style: AnyShapeStyle(style), animatableData: 0), + identity: Pop(style: AnyShapeStyle(style), animatableData: 1) + ) + .animation(.linear(duration: 1.2)) + + return .asymmetric( + insertion: pop, + removal: .identity + ) + } +} + +@available(iOS 15.0, *) +private struct Pop: AnimatableModifier, Animatable, ViewModifier { + var animatableData: CGFloat = 0 + + var style: AnyShapeStyle + + var seed: CGFloat = .random(in: 0 ... 255) + + init(style: AnyShapeStyle, animatableData: CGFloat) { + self.animatableData = animatableData + self.style = style + } + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + let t = clamp(2 * (progress - 1/2.5)) + + content + .scaleEffect(1 - pow(2, -20 * t)) + .overlay { + circleOverlay + } + .background { + particles + } + .animation(nil, value: progress) + } + + @ViewBuilder + var particles: some View { + let t = clamp(2 * (progress - 1/3)) + + var rng = SeededRandomNumberGenerator(seed: seed) + + Canvas { ctx, size in + if t == 0 { return } + + let particleSize = CGSize(width: 3, height: 3) + + let particleCount = 20 + + let radius: CGFloat = min(size.width, size.height) - 22 + + for p in 0 ..< particleCount { + let f: CGFloat = CGFloat.random(in: 0.95 ... 1.1, using: &rng) + + let particleT = clamp(f * (t - (1 - 1/f))) + + if particleT <= 0 { return } + + let particleOpacity: CGFloat = { + if particleT < 0.5 { + return 1 - pow(2, -20 * particleT) + } else { + return 1 - pow(2, 10 * (particleT - 1)) + } + }() + + if particleOpacity <= 0 { return } + + let p: CGFloat = CGFloat(p) + let pFrac: CGFloat = p / CGFloat(particleCount) + + let yOffset = CGFloat.random(in: -2 ... 2, using: &rng) + + let scale = easeOut(1 - particleT) * CGFloat.random(in: 0.8 ... 1.4, using: &rng) + + ctx.drawLayer { ctx in + ctx.translateBy(x: size.width / 2, y: size.height / 2) + + ctx.rotate(by: .degrees(360 * pFrac + CGFloat.random(in: -5 ... 5, using: &rng))) + ctx.translateBy( + x: 0, + y: lerp(easeOut(particleT), outMin: 0, outMax: radius / 2 + yOffset) + ) + ctx.scaleBy(x: scale, y: scale) + + ctx.opacity = clamp(particleOpacity) + + ctx.addFilter(.hueRotation(.degrees(.random(in: -25 ... 25, using: &rng)))) + + let c = Circle().path(in: CGRect(center: .zero, size: particleSize)) + ctx.fill(c, with: .style(style)) + } + } + } + .padding(-30) + .aspectRatio(1, contentMode: .fit) + .allowsHitTesting(false) + } + + @ViewBuilder + var circleOverlay: some View { + let t1 = clamp(1.5 * progress) + let t2 = clamp(1.5 * (progress - 0.15)) + + ZStack { + Circle() + .fill(AnyShapeStyle(style)) + .scaleEffect(1 - pow(2, -14 * t1)) + + Circle() + .foregroundColor(.white) + .scaleEffect(1 - pow(2, -14 * t2)) + .blendMode(.destinationOut) + } + .compositingGroup() + .opacity( + clamp(1 - pow(1.3, -20 * Double(1 - t1))) + ) + .padding(-8) + .allowsHitTesting(false) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Pop_Previews: PreviewProvider { + struct Preview: View { + @State + var favorited = false + + @State + var starred = false + + @State + var commented = false + + @State + var leaf = false + + @State + var count = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Pop") + .bold() + + Text("myView.transition(.movingParts.pop(.red))").padding(.trailing, -8) + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + HStack(alignment: .top) { + Circle() + .fill(.blue) + .overlay { + Text("RB").font(.system(size: 20, design: .rounded)) + } + .aspectRatio(contentMode: .fit) + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text("robb") + Text("@DLX").foregroundColor(.secondary) + } + .font(.subheadline) + .layoutPriority(1) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + + + Text("Trying out button state transitions in SwiftUI.") + + HStack(spacing: 24) { + Button { + withAnimation { + favorited.toggle() + } + } label: { + HStack(spacing: 2) { + Group { + if favorited { + Image(systemName: "heart.fill") + .foregroundColor(.red) + .transition(.movingParts.pop) + } else { + Image(systemName: "heart") + .foregroundColor(.gray) + .transition(.identity) + } + } + + Text((favorited ? 144 : 143).formatted()) + .foregroundColor(favorited ? .red : .gray) + } + } + .tint(.red) + + Button { + withAnimation { + starred.toggle() + } + } label: { + HStack(spacing: 2) { + Group { + if starred { + Image(systemName: "star.fill") + .foregroundStyle(.tint) + .transition(.movingParts.pop) + } else { + Image(systemName: "star") + .foregroundColor(.gray) + .transition(.identity) + } + } + + Text((starred ? 80 : 79).formatted()) + .foregroundColor(starred ? .orange : .gray) + } + } + .tint(.orange) + + Button { + withAnimation { + commented.toggle() + } + } label: { + HStack(spacing: 2) { + Group { + if commented { + Image(systemName: "bubble.right.fill") + .foregroundStyle(.tint) + .transition(.movingParts.pop) + } else { + Image(systemName: "bubble.right") + .foregroundColor(.gray) + .transition(.identity) + } + } + + Text((commented ? 3 : 2).formatted()) + .foregroundColor(commented ? .blue : .gray) + } + } + + Spacer() + } + .padding(.top, 4) + .imageScale(.large) + .font(.footnote.monospacedDigit().weight(.medium)) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Spacer() + } + .padding() + } + .buttonStyle(.plain) + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} +#endif diff --git a/Sources/Pow/Transitions/Skid.swift b/Sources/Pow/Transitions/Skid.swift new file mode 100644 index 0000000..e91a07c --- /dev/null +++ b/Sources/Pow/Transitions/Skid.swift @@ -0,0 +1,265 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// The direction from which to animate in during a `skid` transition's insertion. + enum SkidDirection { + case leading + case trailing + } + + /// A transition that moves the view in from its leading edge with any + /// overshoot resulting in an elastic deformation of the view. + static var skid: AnyTransition { + skid(direction: .leading) + } + + /// A transition that moves the view in from the specified edge during + /// insertion and towards it during removal with any overshoot resulting + /// in an elastic deformation of the view. + /// + /// - Parameter direction: The direction of the transition. + static func skid(direction: SkidDirection) -> AnyTransition { + .modifier( + active: Scaled(Skid(direction, animatableData: 0)), + identity: Scaled(Skid(direction, animatableData: 1)) + ) + } +} + +internal struct Skid: Animatable, GeometryEffect { + var direction: AnyTransition.MovingParts.SkidDirection + + var animatableData: CGFloat = 0 + + internal init(_ direction: AnyTransition.MovingParts.SkidDirection, animatableData: CGFloat = 0) { + self.animatableData = animatableData + self.direction = direction + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let deltaX = -size.width * 2 * (1 - animatableData) + + var t = CGAffineTransform.identity + + t = t.translatedBy(x: size.width / 2, y: size.height / 2) + + let clampedDeltaX = deltaX + + switch direction { + case .leading: + t = t.translatedBy(x: clampedDeltaX, y: 0) + case .trailing: + t = t.translatedBy(x: -clampedDeltaX, y: 0) + } + + let newMainAxisSize = clamp(size.width / 2, size.width - deltaX, size.width * 1.5) + + switch direction { + case .leading: + t = t.translatedBy(x: -size.width * (-1 + (newMainAxisSize / size.width)), y: 0) + t = CGAffineTransformShear(t, -1 + (newMainAxisSize / size.width), 0) + case .trailing: + t = t.translatedBy(x: -size.width * (1 - (newMainAxisSize / size.width)), y: 0) + t = CGAffineTransformShear(t, 1 - (newMainAxisSize / size.width), 0) + } + + t = t.translatedBy(x: -size.width / 2, y: -size.height / 2) + + return ProjectionTransform(t) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Skid_Previews: PreviewProvider { + struct Item: Identifiable { + var color: Color + + let id: UUID = UUID() + + init() { + color = [Color.red, .orange, .yellow, .green, .indigo, .teal].randomElement()! + } + } + + struct Preview: View { + @State + var items: [Item] = [Item()] + + @State + var damping: Double = 0.66 + + @State + var direction: AnyTransition.MovingParts.SkidDirection = .leading + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Skid") + .bold() + + Text("myView.transition(**.movingParts.skid**)\n .animation(.interactiveSpring(\n dampingFraction: \(damping.formatted(.number.precision(.fractionLength(2))))\n )\n)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Slider(value: $damping, in: 0.2 ... 0.8) + + Stepper("Count") { + withAnimation { + items.append(Item()) + } + } onDecrement: { + withAnimation { + if !items.isEmpty { + items.removeLast() + } + } + } + + if #available(iOS 16.0, *) { + LabeledContent("Direction") { + Picker("Direction", selection: $direction) { + Group { + Label("Leading", systemImage: "arrow.forward").tag(AnyTransition.MovingParts.SkidDirection.leading) + Label("Trailing", systemImage: "arrow.backward").tag(AnyTransition.MovingParts.SkidDirection.trailing) + } + } + } + .pickerStyle(.menu) + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(items) { item in + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(item.color) + .overlay { + Text("Jell-O\nWorld") + .blendMode(.difference) + .offset(x: 2, y: 2) + } + .compositingGroup() + .overlay { + Text("Jell-O\nWorld") + } + .font(.system(.headline, design: .rounded).weight(.black)) + .multilineTextAlignment(.center) + .transition( + .asymmetric( + insertion: .movingParts.skid(direction: direction) + .animation(.spring(dampingFraction: damping).speed(0.6)) + .combined(with: .opacity.animation(.easeOut(duration: 0.01))), + removal: .opacity + ) + ) + .aspectRatio(1, contentMode: .fit) + .id(item.id) + } + } + + Spacer() + } + .padding(.horizontal) + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} + +@available(iOS 15.0, *) +struct Skid_2_Previews: PreviewProvider { + struct Preview: View { + @State + var isVisible: Bool = false + + @State + var isRightToLeft: Bool = true + + var body: some View { + VStack { + Toggle("Visible", isOn: $isVisible.animation()) + + Toggle("Right To Left", isOn: $isRightToLeft) + + if #available(iOS 16.0, *) { + LabeledContent("Reference") { + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + } + } else { + HStack { + Text("Reference") + Spacer() + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + } + } + + Spacer() + + let overshoot = Animation.movingParts.overshoot(duration: 0.3) + let mediumSpring = Animation.interactiveSpring(dampingFraction: 0.5) + let looseSpring = Animation.interpolatingSpring(stiffness: 100, damping: 8) + + Group { + if isVisible { + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.skid(direction: .leading).animation(overshoot)) + + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.skid(direction: .leading).animation(mediumSpring)) + + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.skid(direction: .trailing).animation(looseSpring)) + + Color.blue + .frame(width: 120, height: 120) + .transition(.movingParts.move(edge: .leading).animation(looseSpring)) + } + } + + Spacer() + } + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + .padding() + .background { + Color.white.ignoresSafeArea() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + } + } +} +#endif + +private extension CGAffineTransform { + init(skewX x: CGFloat, y: CGFloat) { + self.init(a: 1, b: x, c: y, d: 1, tx: 0, ty: 0) + } +} diff --git a/Sources/Pow/Transitions/Swoosh.swift b/Sources/Pow/Transitions/Swoosh.swift new file mode 100644 index 0000000..eff113b --- /dev/null +++ b/Sources/Pow/Transitions/Swoosh.swift @@ -0,0 +1,102 @@ +import SwiftUI +import simd + +public extension AnyTransition.MovingParts { + /// A three-dimensional transition from the back of the towards the front + /// during insertion and from the front towards the back during removal. + static var swoosh: AnyTransition { + return .modifier( + active: Transform3DEffect( + translation: [-100, -50, -2500], + rotation: + simd_quatd(angle: Angle(degrees: -85).radians, axis: [1, 0, 0]) * + simd_quatd(angle: Angle(degrees: 45).radians, axis: [0, 1, 0]) * + simd_quatd(angle: Angle(degrees: 10).radians, axis: [0, 0, 1]) + , + anchor: .top, + anchorZ: -20, + perspective: 0.16 + ), + identity: Transform3DEffect(perspective: 0.16) + ) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Swoosh_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Swoosh") + .bold() + + Text("myView.transition(**.movingParts.swoosh**)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation(.spring(dampingFraction: 0.8)) { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition( + .movingParts.swoosh.combined(with: .opacity) + ) + .aspectRatio(1.1, contentMode: .fit) + .id(uuid) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Sources/Pow/Transitions/Vanish.swift b/Sources/Pow/Transitions/Vanish.swift new file mode 100644 index 0000000..99a704f --- /dev/null +++ b/Sources/Pow/Transitions/Vanish.swift @@ -0,0 +1,241 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition that dissolves the view into many small particles. + /// + /// The transition is only performed on removal. + static var vanish: AnyTransition { + vanish(.tint) + } + + /// A transition that dissolves the view into many small particles. + /// + /// The transition is only performed on removal. + /// + /// - Parameter style: The style to use for the particles. + /// - Parameter increasedBrightness: A Boolean that indicates whether the particles should render with increased brightness. Defaults to `true`. + /// + /// - Note: This will use a ease-out animation with a duration of 900ms by default. + static func vanish(_ style: T, increasedBrightness: Bool = true) -> AnyTransition { + return .asymmetric( + insertion: .identity, + removal: .modifier( + active: Vanish(animatableData: 0, style: style, increasedBrightness: increasedBrightness) + .defaultAnimation(Vanish.defaultAnimation), + identity: Vanish(animatableData: 1, style: style, increasedBrightness: increasedBrightness) + .defaultAnimation(Vanish.defaultAnimation) + ) + ) + } + + /// A transition that dissolves the view into many small particles. + /// + /// The transition is only performed on removal. + /// + /// - Parameter style: The style to use for the particles. + /// - Parameter mask: A mask to use to determine which particles are inside the view. + /// - Parameter eoFill: A Boolean that indicates whether the shape is interpreted with the even-odd winding number rule. + /// - Parameter increasedBrightness: A Boolean that indicates whether the particles should render with increased brightness. Defaults to `true`. + /// + /// - Note: This will use a ease-out animation with a duration of 900ms by default. + static func vanish(_ style: T, mask: S, eoFill: Bool = false, increasedBrightness: Bool = true) -> AnyTransition { + return .asymmetric( + insertion: .identity, + removal: .modifier( + active: Vanish(animatableData: 0, style: style, mask: mask, eoFill: eoFill, increasedBrightness: increasedBrightness) + .defaultAnimation(Vanish.defaultAnimation), + identity: Vanish(animatableData: 1, style: style, mask: mask, eoFill: eoFill, increasedBrightness: increasedBrightness) + .defaultAnimation(Vanish.defaultAnimation) + ) + ) + } +} + +internal struct Vanish: ViewModifier, Animatable, AnimatableModifier { + static let defaultAnimation: Animation = .easeOut(duration: 0.9) + + var animatableData: CGFloat = 0 + + var style: AnyShapeStyle + + var mask: (any Shape)? + + var eoFill: Bool + + var increasedBrightness: Bool + + @Environment(\.colorScheme) + var colorScheme + + internal init(animatableData: CGFloat = 0, style: S, mask: (any Shape)? = nil, eoFill: Bool = true, increasedBrightness: Bool = true) { + self.animatableData = animatableData + self.style = AnyShapeStyle(style) + self.mask = mask + self.eoFill = eoFill + self.increasedBrightness = increasedBrightness + } + + var progress: CGFloat { + get { animatableData } + set { animatableData = newValue } + } + + func body(content: Content) -> some View { + content + .opacity(progress != 1 ? 0 : 1) + .animation(nil, value: progress) + .overlay { + Canvas { ctx, size in + if progress == 1 { return } + + let bounds = CGRect(origin: .zero, size: size).insetBy(dx: 28, dy: 28) + + let particleSize: CGFloat = 12 + + let rows = Int((bounds.width / particleSize).rounded(.up)) + let cols = Int((bounds.height / particleSize).rounded(.up)) + + var rng = SeededRandomNumberGenerator(seed: size.width) + + let path = mask?.path(in: bounds).cgPath + + for x in 0 ..< rows { + for y in 0 ..< cols { + let x = CGFloat(x) + let y = CGFloat(y) + + var currentParticleSize = particleSize + .random(in: 0 ... 15, using: &rng) + + var center = CGPoint( + x: bounds.minX + CGFloat(x) * particleSize - particleSize / 2, + y: bounds.minY + CGFloat(y) * particleSize - particleSize / 2 + ) + + guard path?.contains(center, using: eoFill ? .evenOdd : .winding) ?? bounds.contains(center) else { + continue + } + + // Center + center.x += (currentParticleSize - currentParticleSize * progress) / 2 + center.y += (currentParticleSize - currentParticleSize * progress) / 2 + + currentParticleSize *= progress + + let particleRect = CGRect( + center: center, + size: CGSize(width: currentParticleSize, height: currentParticleSize) + ) + + let circle = Circle().path(in: particleRect) + + let r = fmod((CGFloat(x) / .pi) + (CGFloat(y * y) / .pi), 1) + + let dX: CGFloat = 6 * particleSize + + let speedUp: CGFloat = 1// + .random(in: -0.1 ... 0.1, using: &rng) + let offsetX: CGFloat = .random(in: -dX / 2 ... dX / 2, using: &rng) + let offsetY: CGFloat = .random(in: -dX / 2 ... dX / 2, using: &rng) + + ctx.drawLayer { ctx in + ctx.translateBy( + x: map(value: 1 - (progress / speedUp), inMin: 0, inMax: 1, outMin: 0, outMax: offsetX), + y: map(value: 1 - (progress / speedUp), inMin: 0, inMax: 1, outMin: 0, outMax: offsetY) + ) + + ctx.opacity = progress - 0.3 * r + + ctx.fill(circle, with: .style(style)) + ctx.addFilter(.blur(radius: 6 * progress)) + ctx.fill(circle, with: .style(style)) + } + + } + } + } + .blur(radius: 6.0 * easeOut(clamp(progress / 14.0))) + .brightness(increasedBrightness ? 4.5 * easeOut(clamp(progress / 18.0)) : 0) + .padding(-25) + .allowsHitTesting(false) + } + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Vanish_Previews: PreviewProvider { + struct Item: Identifiable { + var color: Color + + let id: UUID = UUID() + + init() { + color = [Color.red, .orange, .yellow, .green, .purple, .mint].randomElement()! + } + } + + struct Preview: View { + @State + var items: [Item] = [Item()] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Vanish") + .bold() + + Text("myView.transition(\n **.movingParts.vanish(mask: Capsule())**\n)") + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper("Count") { + withAnimation { + items.append(Item()) + } + } onDecrement: { + withAnimation(.linear(duration: 1.2)) { + if !items.isEmpty { + items.removeLast() + } + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + let shape = Capsule() + + LazyVGrid(columns: columns) { + ForEach(items) { item in + shape + .fill(item.color) + .transition(.movingParts.vanish(.white, mask: shape)) + .aspectRatio(1/1.4, contentMode: .fit) + .id(item.id) + } + } + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + .environment(\.colorScheme, .dark) + } +} +#endif diff --git a/Sources/Pow/Transitions/Wipe.swift b/Sources/Pow/Transitions/Wipe.swift new file mode 100644 index 0000000..db7138d --- /dev/null +++ b/Sources/Pow/Transitions/Wipe.swift @@ -0,0 +1,286 @@ +import SwiftUI + +public extension AnyTransition.MovingParts { + /// A transition using a sweep from the specified edge on insertion, and + /// towards it on removal. + /// + /// - Parameters: + /// - edge: The edge at which the sweep starts or ends. + /// - blurRadius: The radius of the blur applied to the mask. + static func wipe(edge: Edge, blurRadius: CGFloat = 0) -> AnyTransition { + let angle: Angle + + switch edge { + case .top: + angle = .degrees(90) + case .leading: + angle = .degrees(0) + case .bottom: + angle = .degrees(270) + case .trailing: + angle = .degrees(180) + } + + return .modifier( + active: Wipe(angle: angle, blurRadius: blurRadius, progress: 0), + identity: Wipe(angle: angle, blurRadius: blurRadius, progress: 1) + ) + } + + /// A transition using a sweep at the specified angle. + /// + /// The angle is relative to the current `layoutDirection`, such that 0° represents sweeping towards the trailing edge on insertion and 90° represents sweeping towards the bottom edge. + /// + /// In this example, the view insertion is animated by sweeping diagonally + /// from the top leading corner towards the bottom trailing corner. + /// + /// ```swift + /// Text("Hello") + /// .transition( + /// .asymmetric( + /// insertion: .movingParts.wipe(angle: .degrees( 45), blurRadius: 10), + /// removal: .movingParts.wipe(angle: .degrees(225), blurRadius: 10) + /// ) + /// ) + /// ``` + /// + /// - Parameters: + /// - angle: The angle of the animation. + /// - blurRadius: The radius of the blur applied to the mask. + static func wipe(angle: Angle, blurRadius: CGFloat = 0) -> AnyTransition { + .modifier( + active: Wipe(angle: angle, blurRadius: blurRadius, progress: 0), + identity: Wipe(angle: angle, blurRadius: blurRadius, progress: 1) + ) + } +} + +private struct Wipe: ViewModifier, Animatable, AnimatableModifier { + var angle: Angle + + var animatableData: AnimatablePair + + internal init(angle: Angle, blurRadius: CGFloat = 0, progress: CGFloat) { + self.angle = angle + self.animatableData = AnimatableData(progress, clamp(0, blurRadius, 30)) + } + + var progress: CGFloat { + animatableData.first + } + + var blurRadius: CGFloat { + animatableData.second + } + + func body(content: Content) -> some View { + content + .mask( + GeometryReader { proxy in + mask(size: proxy.size) + .blur(radius: blurRadius * (1 - progress)) + .compositingGroup() + } + .padding(-blurRadius) + .animation(nil, value: animatableData) + ) + } + + @ViewBuilder + func mask(size: CGSize) -> some View { + let bounds = CGRect(origin: .zero, size: size).boundingBox(at: angle) + + ZStack(alignment: .leading) { + Color.clear + + Rectangle() + .frame(width: progress * bounds.width) + } + .frame(width: bounds.width, height: bounds.height) + .position( + x: bounds.midX, + y: bounds.midY + ) + .rotationEffect(angle) + .animation(nil, value: progress) + .animation(nil, value: angle) + } +} + +#if os(iOS) && DEBUG +@available(iOS 15.0, *) +struct Wipe_Previews: PreviewProvider { + struct Preview: View { + @State + var indices: [UUID] = [UUID()] + + enum DirectionType: String, Hashable, Identifiable, CaseIterable { + case edge = "Edge" + case angle = "Angle" + + var name: String { + return rawValue + } + + var id: Self { + return self + } + } + + @State + var directionType: DirectionType = .edge + + @State + var edge: Edge = .leading + + @State + var angle: Angle = .degrees(0) + + @State + var blurRadius: CGFloat = 0 + + @State + var isRightToLeft: Bool = false + + func makeTransition() -> AnyTransition { + switch directionType { + case .edge: + return .movingParts.wipe(edge: edge, blurRadius: blurRadius) + case .angle: + return .movingParts.wipe(angle: angle, blurRadius: blurRadius) + } + } + + var resolvedAngle: Angle { + switch directionType { + case .edge: + switch edge { + case .top: + return .degrees(90) + case .leading: + return .degrees(0) + case .bottom: + return .degrees(270) + case .trailing: + return .degrees(180) + } + case .angle: + return angle + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + Text("Wipe") + .bold() + + Text(""" + myView.transition( + .movingParts.wipe(edge: .leading) + ) + """) + } + .font(.footnote.monospaced()) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.thickMaterial) + ) + + Stepper { + Text("View Count ") + Text("(\(indices.count))").foregroundColor(.secondary) + } onIncrement: { + withAnimation { + indices.append(UUID()) + } + } onDecrement: { + if !indices.isEmpty { + let _ = withAnimation { + indices.removeLast() + } + } + } + + Toggle("Right To Left", isOn: $isRightToLeft) + + if #available(iOS 16.0, *) { + Picker("Type", selection: $directionType) { + ForEach(DirectionType.allCases) { type in + Text(type.name).tag(type) + } + } + .pickerStyle(.segmented) + + switch directionType { + case .edge: + LabeledContent("Edge") { + Picker("Edge", selection: $edge) { + Group { + Text("Leading").tag(Edge.leading) + Text("Trailing").tag(Edge.trailing) + Text("Top").tag(Edge.top) + Text("Bottom").tag(Edge.bottom) + } + } + } + .pickerStyle(.menu) + .frame(height: 44) + case .angle: + LabeledContent("Angle") { + AngleControl(angle: $angle) + } + .frame(height: 44) + } + + LabeledContent("Reference") { + Image(systemName: "arrow.forward.circle") + .imageScale(.large) + .rotationEffect(resolvedAngle) + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + } + } + + let columns: [GridItem] = [ + .init(.flexible()), + .init(.flexible()) + ] + + LazyVGrid(columns: columns) { + ForEach(indices, id: \.self) { uuid in + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.accentColor) + + Text("Hello\nWorld!") + .foregroundColor(.white) + .multilineTextAlignment(.center) + .font(.system(.title, design: .rounded)) + + } + .transition( + makeTransition().animation(.easeInOut) + ) + .aspectRatio(1.5, contentMode: .fit) + .id(uuid) + } + } + .environment(\.layoutDirection, isRightToLeft ? .rightToLeft : .leftToRight) + + Spacer() + } + .padding() + } + } + } + + static var previews: some View { + NavigationView { + Preview() + .navigationBarHidden(true) + } + } +} +#endif diff --git a/Tests/PowTests/PowTests.swift b/Tests/PowTests/PowTests.swift new file mode 100644 index 0000000..1656302 --- /dev/null +++ b/Tests/PowTests/PowTests.swift @@ -0,0 +1,6 @@ +import SwiftUI +import XCTest + +import Pow + +final class PowTests: XCTestCase {} diff --git a/images/og-image.png b/images/og-image.png index c364abb..b6fea96 100644 Binary files a/images/og-image.png and b/images/og-image.png differ