大多数浏览器和
Developer App 均支持流媒体播放。
-
利用 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
资源
-
下载Array
-
-
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 } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。