View in English

  • Apple Developer
    • Get Started

    Explore Get Started

    • Overview
    • Learn
    • Apple Developer Program

    Stay Updated

    • Latest News
    • Hello Developer
    • Platforms

    Explore Platforms

    • Apple Platforms
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    • App Store

    Featured

    • Design
    • Distribution
    • Games
    • Accessories
    • Web
    • Home
    • CarPlay
    • Technologies

    Explore Technologies

    • Overview
    • Xcode
    • Swift
    • SwiftUI

    Featured

    • Accessibility
    • App Intents
    • Apple Intelligence
    • Games
    • Machine Learning & AI
    • Security
    • Xcode Cloud
    • Community

    Explore Community

    • Overview
    • Meet with Apple events
    • Community-driven events
    • Developer Forums
    • Open Source

    Featured

    • WWDC
    • Swift Student Challenge
    • Developer Stories
    • App Store Awards
    • Apple Design Awards
    • Apple Developer Centers
    • Documentation

    Explore Documentation

    • Documentation Library
    • Technology Overviews
    • Sample Code
    • Human Interface Guidelines
    • Videos

    Release Notes

    • Featured Updates
    • iOS
    • iPadOS
    • macOS
    • watchOS
    • visionOS
    • tvOS
    • Xcode
    • Downloads

    Explore Downloads

    • All Downloads
    • Operating Systems
    • Applications
    • Design Resources

    Featured

    • Xcode
    • TestFlight
    • Fonts
    • SF Symbols
    • Icon Composer
    • Support

    Explore Support

    • Overview
    • Help Guides
    • Developer Forums
    • Feedback Assistant
    • Contact Us

    Featured

    • Account Help
    • App Review Guidelines
    • App Store Connect Help
    • Upcoming Requirements
    • Agreements and Guidelines
    • System Status
  • Quick Links

    • Events
    • News
    • Forums
    • Sample Code
    • Videos
 

Vidéos

Ouvrir le menu Fermer le menu
  • Collections
  • Toutes les vidéos
  • À propos

Plus de vidéos

  • À propos
  • Code
  • Create custom visual effects with SwiftUI

    Discover how to create stunning visual effects in SwiftUI. Learn to build unique scroll effects, rich color treatments, and custom transitions. We'll also explore advanced graphic effects using Metal shaders and custom text rendering.

    Chapitres

    • 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

    Ressources

    • Creating visual effects with SwiftUI
    • Forum: UI Frameworks
      • Vidéo HD
      • Vidéo SD
  • Rechercher dans cette vidéo…
    • 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: &copy)
                      }
                  } 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
                  }
              }
          }

Developer Footer

  • Vidéos
  • WWDC24
  • Create custom visual effects with SwiftUI
  • Open Menu Close Menu
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    • App Store
    Open Menu Close Menu
    • Swift
    • SwiftUI
    • Swift Playground
    • TestFlight
    • Xcode
    • Xcode Cloud
    • Icon Composer
    • SF Symbols
    Open Menu Close Menu
    • Accessibility
    • Accessories
    • Apple Intelligence
    • Audio & Video
    • Augmented Reality
    • Business
    • Design
    • Distribution
    • Education
    • Games
    • Health & Fitness
    • In-App Purchase
    • Localization
    • Maps & Location
    • Machine Learning & AI
    • Security
    • Safari & Web
    Open Menu Close Menu
    • Documentation
    • Downloads
    • Sample Code
    • Videos
    Open Menu Close Menu
    • Help Guides & Articles
    • Contact Us
    • Forums
    • Feedback & Bug Reporting
    • System Status
    Open Menu Close Menu
    • Apple Developer
    • App Store Connect
    • Certificates, IDs, & Profiles
    • Feedback Assistant
    Open Menu Close Menu
    • Apple Developer Program
    • Apple Developer Enterprise Program
    • App Store Small Business Program
    • MFi Program
    • Mini Apps Partner Program
    • News Partner Program
    • Video Partner Program
    • Security Bounty Program
    • Security Research Device Program
    Open Menu Close Menu
    • Meet with Apple
    • Apple Developer Centers
    • App Store Awards
    • Apple Design Awards
    • Apple Developer Academies
    • WWDC
    Read the latest news.
    Get the Apple Developer app.
    Copyright © 2026 Apple Inc. All rights reserved.
    Terms of Use Privacy Policy Agreements and Guidelines