스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI로 맞춤형 시각 효과 제작하기
SwiftUI에서 놀라운 시각 효과를 제작하는 방법을 알아보세요. 개성 있는 스크롤 효과, 풍부한 색상 처리, 맞춤형 전환 효과를 빌드하는 방법을 소개합니다. Metal 셰이더 및 맞춤형 텍스트 렌더링을 사용한 고급 그래픽 효과도 살펴보세요.
챕터
- 0:00 - Introduction
- 1:29 - Scroll effects
- 6:21 - Color treatments
- 9:10 - View transitions
- 12:49 - Text transitions
- 19:40 - Metal shaders
- 25:28 - Next steps
리소스
-
다운로드
Hello, and welcome to Create Custom Visual Effects in SwiftUI. My name is Philip, and I'll be joined in a bit by Rob. Together, we're going to share how you can create visual effects, to make apps that are more expressive and pleasant to use. Building a great app experience is often the result of making many small improvements. Small details, that in aggregate make a big difference. Visual effects can play an outsized role and how an app is used and perceived. They can show that a feature is working as expected. Add personality to your app surface, and focus attention on something important that's happening. When I'm creating a new visual effect, I'm often not sure what will work, until I start building. I need to experiment, tinker, and play with ideas until things feel right. In this session, Rob and I are going to build out a number of examples and explore how using SwiftUI, you can create custom scroll effects, bring rich color treatment to your apps with mesh gradients, compose custom view transitions, create beautiful text transitions using text renders, and write metal shaders to create advanced graphic effects. We're going to start with something we're all probably familiar with, scrolling. So much of our app experiences, are collections of items that we scroll through. Whether they be photos, videos, or text blocks. Scroll views are everywhere.
Here, I have a simple collection of photos inside a horizontal scrollview. In SwiftUI, scroll views provide a lot of automatic support for common use cases.
Here, I'm using a paging behavior to get a pagination effect. This is fine for a standard scroll view. But I'd like to create something a bit more unique.
Let's take a look at a single photo.
SwiftUI's scroll transition modifier, can be used to change a standard collection of elements into something custom.
Scroll transition exposes the content I'd like to transition, as well as a phase.
I can use these values to change the rotation, and offset of each photo in my scroll view. Based on its position.
As I scroll, the photos on the leading and trailing edges are rotated, creating a circular carousel effect.
I can use the value property to determine how far off screen my images is and use that for rotation. And if my view is fully on screen, the isIdentity property will be true.
This rotation effect is nice, but it doesn't really fit the metaphor I'd like to use here. I'd like each of these cards to feel as if they're a window, that you can look through.
By changing the modifiers, my scrollTransition is updating. I have the ability to completely change the feel of this scrollview to create a parallax effect.
Here I’m using the scrollTransition to change the xOffset of the image but not the shape that’s clipping it. scrollTransition can be used to manipulate this content in tons of different ways! I can take this modifier and put it on any content that I want to update based on the scroll value. Here I’ve added a scroll transition to a text caption, underneath my image so that it fades out and offsets to amplify the momentum of the scrollview.
ScrollTransitions are a great way to build interesting, unique scroll experiences. Sometimes though you need a bit more control over how a views position or size affects its visual appearance.
Here I have a simple collection of grocery items that I can scroll through. Right now, each item is the same color, which looks monotonous.
I can add a visualEffect modifier which provides access to a content placeholder and a proxy. The content placeholder works the same as in the scrollTransition. The proxy can give me the geometry values of the view.
I can use the location of the view from the proxy and use it to change the hue of my view which creates a nice gradient effect. The lower the view on my device, the stronger the hue rotation.
The visual effect modifier lets you change visual properties based on the view position and size in a performant way, which means it’s great for use in scrollviews.
Instead of changing the color, I could change other visual properties. Here I’m taking the same y position of my shape, and using it to offset, scale, fade, and blur an element as it gets to the top of the scrollview. The ScrollTransition and VisualEffect modifiers are great ways to create custom scrollview effects.
You can use them to create scrollviews that adjust scale based on the position of an element on the screen.
You could use them to change the perspective, by using different transforms like rotation and skew.
Use offsets to create stacking behavior, or adjust color properties like brightness, saturation and hue to create emphasis and provide clarity.
It’s not always clear though if an effect is right for your app, or if it’s distracting. It’s helpful to spend time living with visual experiments. Visual effects should be pleasant to use well after the novelty has worn off. Testing your effects over time, and in different contexts will help reinforce if an effect is working, or where it still needs improvement. Next, let’s talk about how you can bring color effects to your app. Color plays an important role in an interface. It can help give your app an identity, focus attention, or clarify intent. SwiftUI has a lot of tools for bringing color into your app. There’s support for different gradient types, color controls, blend modes and much more.
New in SwiftUI is support for Mesh Gradients. Mesh gradients are useful when you want a dynamic background, or need to add some visual distinction to a surface.
Mesh Gradients are made from a grid of points. Each of these of these points has a color associated with it.
SwiftUI interpolates between these colors on the grid to create a color fill.
These points can be moved to create beautiful color effects. The colors blend together smoothly, and points that are closer together have sharper color transition.
In order to create a Mesh Gradient, I’ll use the new MeshGradient view.
I’ll define the rows and columns of my grid using the width and height parameters. In this case, I’ll use a 3 by 3 grid.
Next, we’re going to define where the X and Y coordinates on this 3x3 grid are located. Points in the grid are defined using SIMD2 float values. When used as a view, these floats take a value from 0 to 1 on the X and Y axis.
Finally, I’ll add a corresponding color for each of these points.
This creates our mesh gradient! Right now it looks a bit like a linear gradient. If I move the X and Y coordinates of the center point, the colors move to match the new position! Mesh gradients are a nice way to add color effects to your app, and you can use them to create all sorts of visual effects. They can be purely decorative, but you can also use them to match a surface with imagery, or even signal that something has changed through a mesh gradient animation! Play around with values, like the position of control points, grid size, and color palette. Tweaking parameters, and exploring the edges of what’s visually possible will lead you far beyond any ideas you have at the beginning, so be bold! Turn the dials up to 100 and make something new! Next let’s talk about creating custom transitions. Our interfaces are a portal into what are app is doing behind the scenes, and transitions are a useful way to communicate the changes that are happening.
Transitions are useful when you want to show new views, or remove views that aren’t needed anymore.
They can help provide context as to what changed and why the change occurred. Sometimes these transitions are due to the tap of a button, or the drag of an element. Sometimes they are triggered by the behavior of someone else using an app.
I have an Avatar View that shows and hides based on that persons online status. If they are online, I’d like their avatar to show, and otherwise it should be hidden. Right now, it just appears and disappears. This is a bit jarring, so let's add a transition.
We can apply one of SwiftUI's standard transitions, like scale, to have it scale up and down as it enters and exits.
If we want to change multiple transitions, we can use the combined method to add another. Let’s combine our scale transition with opacity.
This is looking better, but what if we want something more custom? In order to create a custom transition, I’ll create a new Struct. I'll call it Twirl. This will conform to the Transition protocol.
The Transition body function takes a content and phase parameter. The content parameter works the same as what I shared for the scrollviews, as a placeholder for the content I want to transition. I can use the phase value to check if a view is currently being shown and use that to conditionally style my view. For scale, I’d like it to be at full scale when it’s shown, and at half the scale when it’s not For opacity, I’d like my element to toggle between fully visible and hidden.
I can attach my custom transition to my view and check out the result.
Back in my custom transition, I’d like to add blur so that it looks like the avatar is coming in and out of focus. And also add some rotation so that it spins.
I can check my phase value for whether or not a view will appear, or did disappear. This will let me continue rotating the same direction on exit, by using a negative value.
Finally, I’ll add a brightness modifier, so that when the view is entering it has a bit of shine and catches attention.
With a few small adjustments, we're able to make our interface element respond to changes in a graceful way.
Transitions can be used in many types of scenarios. To ease an element into view as it’s loading, introduce an important piece of information. Or make a graphical element feel dynamic.
A good transition will fit naturally within its larger context, and not feel like it was tacked on. Looking at your app holistically, can help you decide what transitions are the right fit for your app. Speaking of transitions, I’ll hand it off to Robb to talk about text transitions.
Thanks, Phillip. Let's dive in.
Philip already explained how to use the built-in SwiftUI transitions to animate in Views, like this opacity transition. While I could definitely spice it up using the built-in modifiers, I'd like to try animating the Text in line-by-line.
To do this, I'll use TextRenderer. A new API introduced in iOS 18 and aligned releases. TextRenderer is a powerful new protocol that allows you to customize, how SwiftUI Text is drawn for an entire View tree. This enables a whole new range of custom Text drawing possibilities, but the one I'm most excited about is animation.
The core of the TextRenderer protocol is the draw(layout:in:) method. Its arguments are a Text.Layout and a GraphicsContext. Text.Layout is what allows us to access the individual components of the Text, its Lines, Runs and Glyphs. The GraphicsContext is the same type that is used by the Canvas view. Check out Add rich graphics to your SwiftUI app, if you'd like to know more about how to draw with it.
For a minimal TextRenderer, I only need to iterate over the individual lines of the layout using a for-loop and draw them into the context. This will give me the default rendering behavior.
To drive my transition, I add three properties to my TextRenderer, elapsedTime: how much time has passed so far. elementDuration: how much time should be spent animating an individual line or character. and totalDuration: How much time the entire transition will take. To have SwiftUI automatically animate the elapsedTime value for me, I implement the Animatable protocol. It's simple to adopt in this case by forwarding the animatableData property to elapsedTime.
Now I can start iterating on my animation. First, I'll try animating line by line. To distribute the available time evenly across the animation, I need to calculate the amount of delay, between two consecutive lines using this helper function I called, elementDelay(count:). Next, I enumerate all lines and calculate their relative start time, based on their index and that delay value. The time that has passed for an individual line is the overall elapsed time, minus the element's individual time offset. I also clamp this value. Next, I create a copy of the current graphics context. This will make sure that individual calls to my helper function will not affect each other since GraphicsContext has value semantics. Finally I call my helper function to draw the individual line.
This is where the magic happens. Before I draw the line, I update the properties of the GraphicsContext that I want to animate. To make this easier, I also calculate fractional progress value.
First, I want the line to fade-in so I calculate a quick opacity ramp.
At the same time, I decrease its blur radius to 0 to give the impression the line manifests from a diffuse state.
The initial blurRadius is based on the height of the line that I read from the line's typographicBounds property.
Lastly, I animate a translation on the y axis using a spring.
I start at a y position that is shifted upwards based on the length of the line's descender. Finally, I draw the line using the new draw options method.
By opting out of subpixel quantization, I can avoid jitter as my spring settles.
In order to use the Renderer to animate in Text, I implement a custom Transition like Philip explained earlier. By experimenting, I found that 0.9 seconds feels like a good duration for my use case. However, I need to consider that there could already be an animation on the current transaction. For example, when this transition was triggered from a call to withAnimation.
Using the transaction body view modifier, I can override the animation when appropriate. This way, I can ensure an even, linear pacing for every line. Then, I use the new textRenderer view modifier and set my custom renderer on the view being transitioned in or out.
Here's the Transition in action.
I like it, but I don't love it. It's dependent on the number of lines, which can change based on locale or Dynamic Type size. Also, it doesn't quite capture my excitement for Visual Effects. Let's try animating every glyph individually.
To do that, I need iterate over the Text.Layout's run slices. They represent the smallest unit of layout, like glyphs or embedded images.
A Text.Layout is a Collection of lines. A line is a Collection of Runs and a Run is a collection of RunSlices.
Therefore, using this helper method called flattenedRunSlices, I just need to iterate over the RunSlices instead and get to keep almost all of my logic.
I also need to revisit my helper function, but all I need to do here its change the type and name of its Line argument, to a RunSlice.
Here's the result in action. I think that's better, but now I have the opposite problem. There's very little time left in the animation to dedicate to an individual glyph. This reduces the overall impact, making the transition feel less fun and a little same-y? I think I need to dial it back a little. Instead of animating everything the same way, I'll focus only on the words Visual Effects.
That way I can use the Transition, not only to bring in the content but also to emphasize what's important.
To do that, I'm using the new TextAttribute protocol introduced alongside TextRenderer in iOS 18 and aligned releases. By implementing this protocol, I can pass data from my Text, to my TextRenderer.
Applying the attribute is very simple. Using the customAttribute Text modifier, I mark the words Visual Effects using my custom EmphasisAttribute. Because it's only used to mark a range of text, I don't actually need to add any member variables to my TextAttribute struct.
Revisiting the draw method one last time, I now iterate over the flattened runs of my layout. I check the presence of the EmphasisAttribute on the run using a subscript with the Attribute-Type as its key. If the attribute is present, I iterate over the slices in the exact same manner as I did before. If the attribute is absent, I'll quickly fade in the run over the course of 0.2 seconds.
Here's the final result. This is much better. The Transition now really emphasizes Visual Effects.
TextRenderer opens a whole range of new possibilities. By breaking a View into smaller components that animate individually, you can build more expressive animations and visual effects. And there's another powerful graphics API in SwiftUI, that offers even more fine-grained control, Shaders. Shaders are small programs that calculate various rendering effects, directly on your device's GPU. SwiftUI uses shaders internally to implement many of the visual effects Philip showed you earlier, like the new Mesh Gradients. With SwiftUI Shaders introduced in iOS 17 and aligned releases, you'll be able to unlock the same level of performance and write your own impressive effects.
You instantiate a Shader in SwiftUI by calling a function with its name on ShaderLibrary. Here, you can also pass additional parameters to your Shader function, like colors, numbers, or an image. When you apply this effect to a view using the layerEffect view modifier, SwiftUI will call your shader function for every single pixel of your view.
That's a lot of pixels. To make this possible in real time, Shaders run on your device's GPU which is optimized for highly parallel tasks such as this. However, because of the specialized nature of GPU programming, the Shaders themselves cannot be written in Swift. Instead, they are written in the Metal Shading Language, or Metal for short.
Here's the corresponding Metal file of the Shader I showed you earlier. The name of the Shader function matches the invocation on ShaderLibrabry.
This is the function that SwiftUI will execute, on the GPU, for each of your views pixels and when it does, the position argument refers to that pixel's location. The layer argument meanwhile is a representation of your views content. You can sample the layer to obtain its contents, but you must stay within the maxSampleOffset that the shader was instantiated with, relative to position.
SwiftUI also resolves and converts types, such as Color to representations that can be used in Metal. Here, my pink color gets converted to a half4. Metal makes heavy use of vector types like this, a half4 is a four-component vector of 16-bit floating point numbers. This type encodes the red, green, blue and alpha components of the color. Similarly, float2 is a two-component vector of 32-bit floating point numbers and frequently used for 2D points or dimensions.
In SwifUI, Shaders can be used for Custom Fills and 3 kinds of Effects: Color Effects, Distortion Effects, and Layer Effects. Of the three Effects, Layer Effects are the most powerful and effectively a superset of the other two, so I'll show you how to write a Layer Effect.
Currently, I have this PushEffect installed on my view that is triggered whenever I tap it. The view scales down using a spring, then immediately pops back up. This gives me direct feedback for my interaction, but the animation does not respond to where I touch it. This makes it feel lifeless and stiff.
Instead, I would like it to look more like this. Whenever I touch the view, the scale effect spreads outwards from the touch location. Affecting every pixel of my view differently. With SwiftUI Shaders, I now have the tools I need to make an effect like this reality.
To implement this effect, I add a new Shader function to my metal file that I call Ripple. I add the two arguments required by the layer effect API, position and Layer.
I've already worked out the formula that describes each pixel's output. It's a function of the point at which the view was touched, how much time has passed as well as these four parameters.
I calculate the distortion for this pixel, leaving me with this newPosition value. This is where I sample the view.
After some tweaking based on the strength of the distortion, I return the modified color. Next, I need to call this Shader Function from SwiftUI.
To do that, I create this ViewModifier called RippleModifier, exposes all the parameters of the Shader function to SwiftUI. In its body(content:) method, it instantiates the shader and applies it to its content.
Because Shaders have no concept of time, we also need to drive the animation from SwiftUI.
Here's how I do that, I wrote a second ViewModifier called RippleEffect. The keyframeAnimator view modifier makes it easy, to run animations based on external changes, like gestures. I animate the elapsedTime from zero to its final duration value, whenever the trigger value updates. This way, at every step of the animation, RippleModifier will be passed the current time and the origin point at which I touched the view.
But wait, I never assigned values to the four parameters I showed you earlier. And I'll be honest, I have no idea what values would look good here. I'll just have to experiment, so I built myself this debug UI.
Because RippleModifier does perform any animation itself, I can use it to scrub forwards and backwards, through the animation interactively. This way, I can dial in the right parameters for my shader function on my Phone or inside an Xcode preview.
Building great experiences requires a lot of trial and error, and Debug UI is a great way to iterate on complex animations. This can mean exposing parameters or drawing an overlay, that visualizes intermediate values. Getting immediate feedback like this is incredibly powerful and makes it easier to quickly iterate. And that’s important because there are so many possibilities, for what you can create with Shaders.
You can use Shaders to create an animated fill to add texture to your app. You can combine Shaders and TextRenderer to apply distortion to Text, or use them to create gradient maps for unique photo effects.
In this video we looked at a number of ways to create visual effects with SwiftUI. And we encourage you to put your own spin on these ideas.
Experiment with custom scroll effects to set your app apart. Add a splash of color with mesh gradients. Treat your app to some custom view transitions. Make text come alive with the new text renderer API. Build a wild new experience with a Metal shader.
Use these tools to invent something new! Thank you for watching.
-
-
1:45 - Scroll view with pagination
ScrollView(.horizontal) { LazyHStack(spacing: 22) { ForEach(animals, id: \.self) { animal in AnimalPhoto(image: animal) } }.scrollTargetLayout() } .contentMargins(.horizontal, 44) .scrollTargetBehavior(.paging)
-
2:30 - Rotation effect
AnimalPhoto(image: animal) .scrollTransition( axis: .horizontal ) { content, phase in content .rotationEffect(.degrees(phase.value * 2.5)) .offset(y: phase.isIdentity ? 0 : 8) }
-
3:14 - Parallax Effect
ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(animals, id: \.self) { animal in VStack(spacing: 8) { ZStack { AnimalPhoto(image: animal) .scrollTransition( axis: .horizontal ) { content, phase in return content .offset(x: phase.value * -250) } } .containerRelativeFrame(.horizontal) .clipShape(RoundedRectangle(cornerRadius: 32)) } }.scrollTargetLayout() } .contentMargins(.horizontal, 32) .scrollTargetBehavior(.paging)
-
4:41 - Visual effect hue rotation
RoundedRectangle(cornerRadius: 24) .fill(.purple) .visualEffect({ content, proxy in content .hueRotation(Angle(degrees: proxy.frame(in: .global).origin.y / 10)) })
-
7:30 - Mesh gradient
MeshGradient( width: 3, height: 3, points: [ [0.0, 0.0], [0.5, 0.0], [1.0, 0.0], [0.0, 0.5], [0.9, 0.3], [1.0, 0.5], [0.0, 1.0], [0.5, 1.0], [1.0, 1.0] ], colors: [ .black,.black,.black, .blue, .blue, .blue, .green, .green, .green ] )
-
10:36 - Custom transition
struct Twirl: Transition { func body(content: Content, phase: TransitionPhase) -> some View { content .scaleEffect(phase.isIdentity ? 1 : 0.5) .opacity(phase.isIdentity ? 1 : 0) .blur(radius: phase.isIdentity ? 0 : 10) .rotationEffect( .degrees( phase == .willAppear ? 360 : phase == .didDisappear ? -360 : .zero ) ) .brightness(phase == .willAppear ? 1 : 0) } }
-
13:29 - The Minimum Viable TextRenderer
// The Minimum Viable TextRenderer struct AppearanceEffectRenderer: TextRenderer { func draw(layout: Text.Layout, in context: inout GraphicsContext) { for line in layout { context.draw(line) } } }
-
14:01 - A Custom Text Transition
import SwiftUI #Preview("Text Transition") { @Previewable @State var isVisible: Bool = true VStack { GroupBox { Toggle("Visible", isOn: $isVisible.animation()) } Spacer() if isVisible { let visualEffects = Text("Visual Effects") .customAttribute(EmphasisAttribute()) .foregroundStyle(.pink) .bold() Text("Build \(visualEffects) with SwiftUI 🧑💻") .font(.system(.title, design: .rounded, weight: .semibold)) .frame(width: 250) .transition(TextTransition()) } Spacer() } .multilineTextAlignment(.center) .padding() } struct EmphasisAttribute: TextAttribute {} /// A text renderer that animates its content. struct AppearanceEffectRenderer: TextRenderer, Animatable { /// The amount of time that passes from the start of the animation. /// Animatable. var elapsedTime: TimeInterval /// The amount of time the app spends animating an individual element. var elementDuration: TimeInterval /// The amount of time the entire animation takes. var totalDuration: TimeInterval var spring: Spring { .snappy(duration: elementDuration - 0.05, extraBounce: 0.4) } var animatableData: Double { get { elapsedTime } set { elapsedTime = newValue } } init(elapsedTime: TimeInterval, elementDuration: Double = 0.4, totalDuration: TimeInterval) { self.elapsedTime = min(elapsedTime, totalDuration) self.elementDuration = min(elementDuration, totalDuration) self.totalDuration = totalDuration } func draw(layout: Text.Layout, in context: inout GraphicsContext) { for run in layout.flattenedRuns { if run[EmphasisAttribute.self] != nil { let delay = elementDelay(count: run.count) for (index, slice) in run.enumerated() { // The time that the current element starts animating, // relative to the start of the animation. let timeOffset = TimeInterval(index) * delay // The amount of time that passes for the current element. let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration)) // Make a copy of the context so that individual slices // don't affect each other. var copy = context draw(slice, at: elementTime, in: ©) } } else { // Make a copy of the context so that individual slices // don't affect each other. var copy = context // Runs that don't have a tag of `EmphasisAttribute` quickly // fade in. copy.opacity = UnitCurve.easeIn.value(at: elapsedTime / 0.2) copy.draw(run) } } } func draw(_ slice: Text.Layout.RunSlice, at time: TimeInterval, in context: inout GraphicsContext) { // Calculate a progress value in unit space for blur and // opacity, which derive from `UnitCurve`. let progress = time / elementDuration let opacity = UnitCurve.easeIn.value(at: 1.4 * progress) let blurRadius = slice.typographicBounds.rect.height / 16 * UnitCurve.easeIn.value(at: 1 - progress) // The y-translation derives from a spring, which requires a // time in seconds. let translationY = spring.value( fromValue: -slice.typographicBounds.descent, toValue: 0, initialVelocity: 0, time: time) context.translateBy(x: 0, y: translationY) context.addFilter(.blur(radius: blurRadius)) context.opacity = opacity context.draw(slice, options: .disablesSubpixelQuantization) } /// Calculates how much time passes between the start of two consecutive /// element animations. /// /// For example, if there's a total duration of 1 s and an element /// duration of 0.5 s, the delay for two elements is 0.5 s. /// The first element starts at 0 s, and the second element starts at 0.5 s /// and finishes at 1 s. /// /// However, to animate three elements in the same duration, /// the delay is 0.25 s, with the elements starting at 0.0 s, 0.25 s, /// and 0.5 s, respectively. func elementDelay(count: Int) -> TimeInterval { let count = TimeInterval(count) let remainingTime = totalDuration - count * elementDuration return max(remainingTime / (count + 1), (totalDuration - elementDuration) / count) } } extension Text.Layout { /// A helper function for easier access to all runs in a layout. var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> { self.flatMap { line in line } } /// A helper function for easier access to all run slices in a layout. var flattenedRunSlices: some RandomAccessCollection<Text.Layout.RunSlice> { flattenedRuns.flatMap(\.self) } } struct TextTransition: Transition { static var properties: TransitionProperties { TransitionProperties(hasMotion: true) } func body(content: Content, phase: TransitionPhase) -> some View { let duration = 0.9 let elapsedTime = phase.isIdentity ? duration : 0 let renderer = AppearanceEffectRenderer( elapsedTime: elapsedTime, totalDuration: duration ) content.transaction { transaction in // Force the animation of `elapsedTime` to pace linearly and // drive per-glyph springs based on its value. if !transaction.disablesAnimations { transaction.animation = .linear(duration: duration) } } body: { view in view.textRenderer(renderer) } } }
-
22:55 - A simple ripple effect Metal shader
// Insert #include <metal_stdlib> #include <SwiftUI/SwiftUI.h> using namespace metal; [[ stitchable ]] half4 Ripple( float2 position, SwiftUI::Layer layer, float2 origin, float time, float amplitude, float frequency, float decay, float speed ) { // The distance of the current pixel position from `origin`. float distance = length(position - origin); // The amount of time it takes for the ripple to arrive at the current pixel position. float delay = distance / speed; // Adjust for delay, clamp to 0. time -= delay; time = max(0.0, time); // The ripple is a sine wave that Metal scales by an exponential decay // function. float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time); // A vector of length `amplitude` that points away from position. float2 n = normalize(position - origin); // Scale `n` by the ripple amount at the current pixel position and add it // to the current pixel position. // // This new position moves toward or away from `origin` based on the // sign and magnitude of `rippleAmount`. float2 newPosition = position + rippleAmount * n; // Sample the layer at the new position. half4 color = layer.sample(newPosition); // Lighten or darken the color based on the ripple amount and its alpha // component. color.rgb += 0.3 * (rippleAmount / amplitude) * color.a; return color; }
-
23:36 - A Custom Ripple Effect
import SwiftUI #Preview("Ripple") { @Previewable @State var counter: Int = 0 @Previewable @State var origin: CGPoint = .zero VStack { Spacer() Image("palm_tree") .resizable() .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .onPressingChanged { point in if let point { origin = point counter += 1 } } .modifier(RippleEffect(at: origin, trigger: counter)) .shadow(radius: 3, y: 2) Spacer() } .padding() } #Preview("Ripple Editor") { @Previewable @State var origin: CGPoint = .zero @Previewable @State var time: TimeInterval = 0.3 @Previewable @State var amplitude: TimeInterval = 12 @Previewable @State var frequency: TimeInterval = 15 @Previewable @State var decay: TimeInterval = 8 VStack { GroupBox { Grid { GridRow { VStack(spacing: 4) { Text("Time") Slider(value: $time, in: 0 ... 2) } VStack(spacing: 4) { Text("Amplitude") Slider(value: $amplitude, in: 0 ... 100) } } GridRow { VStack(spacing: 4) { Text("Frequency") Slider(value: $frequency, in: 0 ... 30) } VStack(spacing: 4) { Text("Decay") Slider(value: $decay, in: 0 ... 20) } } } .font(.subheadline) } Spacer() Image("palm_tree") .resizable() .aspectRatio(contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .modifier(RippleModifier(origin: origin, elapsedTime: time, duration: 2, amplitude: amplitude, frequency: frequency, decay: decay)) .shadow(radius: 3, y: 2) .onTapGesture { origin = $0 } Spacer() } .padding(.horizontal) } struct PushEffect<T: Equatable>: ViewModifier { var trigger: T func body(content: Content) -> some View { content.keyframeAnimator( initialValue: 1.0, trigger: trigger ) { view, value in view.visualEffect { view, _ in view.scaleEffect(value) } } keyframes: { _ in SpringKeyframe(0.95, duration: 0.2, spring: .snappy) SpringKeyframe(1.0, duration: 0.2, spring: .bouncy) } } } /// A modifer that performs a ripple effect to its content whenever its /// trigger value changes. struct RippleEffect<T: Equatable>: ViewModifier { var origin: CGPoint var trigger: T init(at origin: CGPoint, trigger: T) { self.origin = origin self.trigger = trigger } func body(content: Content) -> some View { let origin = origin let duration = duration content.keyframeAnimator( initialValue: 0, trigger: trigger ) { view, elapsedTime in view.modifier(RippleModifier( origin: origin, elapsedTime: elapsedTime, duration: duration )) } keyframes: { _ in MoveKeyframe(0) LinearKeyframe(duration, duration: duration) } } var duration: TimeInterval { 3 } } /// A modifier that applies a ripple effect to its content. struct RippleModifier: ViewModifier { var origin: CGPoint var elapsedTime: TimeInterval var duration: TimeInterval var amplitude: Double = 12 var frequency: Double = 15 var decay: Double = 8 var speed: Double = 1200 func body(content: Content) -> some View { let shader = ShaderLibrary.Ripple( .float2(origin), .float(elapsedTime), // Parameters .float(amplitude), .float(frequency), .float(decay), .float(speed) ) let maxSampleOffset = maxSampleOffset let elapsedTime = elapsedTime let duration = duration content.visualEffect { view, _ in view.layerEffect( shader, maxSampleOffset: maxSampleOffset, isEnabled: 0 < elapsedTime && elapsedTime < duration ) } } var maxSampleOffset: CGSize { CGSize(width: amplitude, height: amplitude) } } extension View { func onPressingChanged(_ action: @escaping (CGPoint?) -> Void) -> some View { modifier(SpatialPressingGestureModifier(action: action)) } } struct SpatialPressingGestureModifier: ViewModifier { var onPressingChanged: (CGPoint?) -> Void @State var currentLocation: CGPoint? init(action: @escaping (CGPoint?) -> Void) { self.onPressingChanged = action } func body(content: Content) -> some View { let gesture = SpatialPressingGesture(location: $currentLocation) content .gesture(gesture) .onChange(of: currentLocation, initial: false) { _, location in onPressingChanged(location) } } } struct SpatialPressingGesture: UIGestureRecognizerRepresentable { final class Coordinator: NSObject, UIGestureRecognizerDelegate { @objc func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer ) -> Bool { true } } @Binding var location: CGPoint? func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { Coordinator() } func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { let recognizer = UILongPressGestureRecognizer() recognizer.minimumPressDuration = 0 recognizer.delegate = context.coordinator return recognizer } func handleUIGestureRecognizerAction( _ recognizer: UIGestureRecognizerType, context: Context) { switch recognizer.state { case .began: location = context.converter.localLocation case .ended, .cancelled, .failed: location = nil default: break } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.