はじめに
WWDC24で紹介されていた「Create custom visual effects with SwiftUI」が感動したので紹介します。実際のコードも各UI毎に作成したので是非試してみてください!23分の動画を2分で読めます!
前提条件
カスタムビジュアルエフェクト
Scroll effects
写真のコレクションを横方向のスクロールビューで表示し、スクロールトランジションを使用してカスタムエフェクトを作成します。
エフェクトなし | エフェクト1 | エフェクト2 |
---|---|---|
スクロールエフェクトなし
struct ContentView: View {
let photos = [
Photo("0"),
Photo("1"),
Photo("2"),
Photo("3"),
Photo("4"),
Photo("5"),
Photo("6")
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(photos) { photo in
VStack {
ItemPhoto(photo)
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 36))
}
}
}
}
.contentMargins(32)
.scrollTargetBehavior(.paging)
}
}
#Preview {
ContentView()
}
struct Photo: Identifiable {
var title: String
var id: Int = .random(in: 0 ... 100)
init(_ title: String) {
self.title = title
}
}
struct ItemPhoto: View {
var photo: Photo
init(_ photo: Photo) {
self.photo = photo
}
var body: some View {
Image(photo.title)
.resizable()
.scaledToFill()
.frame(height: 400)
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 36))
}
}
スクロールエフェクト1
struct ContentView: View {
let photos = [
Photo("0"),
Photo("1"),
Photo("2"),
Photo("3"),
Photo("4"),
Photo("5"),
Photo("6")
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(photos) { photo in
VStack {
ZStack {
ItemPhoto(photo)
.scrollTransition(axis: .horizontal) { content, phase in
content
.rotationEffect(.degrees(phase.value * 3.5))
.offset(y: phase.isIdentity ? 0 : 16)
}
}
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 36))
}
}
}
}
.contentMargins(32)
.scrollTargetBehavior(.paging)
}
}
#Preview {
ContentView()
}
struct Photo: Identifiable {
var title: String
var id: Int = .random(in: 0 ... 100)
init(_ title: String) {
self.title = title
}
}
struct ItemPhoto: View {
var photo: Photo
init(_ photo: Photo) {
self.photo = photo
}
var body: some View {
Image(photo.title)
.resizable()
.scaledToFill()
.frame(height: 400)
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 36))
}
}
スクロールエフェクト2
struct ContentView: View {
let photos = [
Photo("0"),
Photo("1"),
Photo("2"),
Photo("3"),
Photo("4"),
Photo("5"),
Photo("6")
]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(photos) { photo in
VStack {
ZStack {
ItemPhoto(photo)
.scrollTransition(axis: .horizontal) { content, phase in
content
.offset(x: phase.isIdentity ? 0 : phase.value * -200)
}
}
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 36))
}
}
}
}
.contentMargins(32)
.scrollTargetBehavior(.paging)
}
}
#Preview {
ContentView()
}
struct Photo: Identifiable {
var title: String
var id: Int = .random(in: 0 ... 100)
init(_ title: String) {
self.title = title
}
}
struct ItemPhoto: View {
var photo: Photo
init(_ photo: Photo) {
self.photo = photo
}
var body: some View {
Image(photo.title)
.resizable()
.scaledToFill()
.frame(height: 400)
.containerRelativeFrame(.horizontal)
.clipShape(RoundedRectangle(cornerRadius: 36))
}
}
struct ItemLabel: View {
var photo: Photo
init(_ photo: Photo) {
self.photo = photo
}
var body: some View {
Text(photo.title)
.font(.title)
}
}
色合い変更なし | 色合い変更1 | 色合い変更2 |
---|---|---|
色合い変更なし
struct ContentView: View {
var body: some View {
ScrollView(.vertical) {
VStack {
ForEach(0 ..< 20) { _ in
RoundedRectangle(cornerRadius: 24)
.fill(.purple)
.frame(height: 100)
}
}
.padding()
}
}
}
#Preview {
ContentView()
}
色合い変更1
struct ContentView: View {
var body: some View {
ScrollView(.vertical) {
VStack {
ForEach(0 ..< 20) { _ in
RoundedRectangle(cornerRadius: 24)
.fill(.blue)
.frame(height: 100)
.visualEffect { content, proxy in
content
.hueRotation(.degrees(proxy.frame(in: .global).origin.y / 10))
}
}
}
.padding()
}
}
}
#Preview {
ContentView()
}
色合い変更2
struct ContentView: View {
var body: some View {
ScrollView(.vertical) {
VStack {
ForEach(0 ..< 20) { _ in
RoundedRectangle(cornerRadius: 24)
.fill(.blue)
.frame(height: 100)
.visualEffect { content, proxy in
let frame = proxy.frame(in: .scrollView(axis: .vertical))
let parentBounds = proxy
.bounds(of: .scrollView(axis: .vertical)) ??
.infinite
let distance = min(0, frame.minY)
return content
.hueRotation(.degrees(frame.origin.y / 10))
.scaleEffect(1 + distance / 700)
.offset(y: -distance / 1.25)
.brightness(-distance / 400)
.blur(radius: -distance / 50)
}
}
}
.padding()
}
}
}
#Preview {
ContentView()
}
Color treatments
メッシュグラデーションです。カスタマイズ性のあがったリニアグラディエントと言ったところでしょうか。ポイントごとの色を補間してグラデーションを作ります。
メッシュグラディエント1 | メッシュグラディエント2 | メッシュグラディエント3 |
---|---|---|
メッシュグラディエント1
struct ContentView: View {
var body: some View {
MeshGradient(
width: 3,
height: 3,
points: [
[0.0, 0.0], [0.5, 0.0], [1.0, 0.0],
[0.0, 0.5], [0.8, 0.2], [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
])
.edgesIgnoringSafeArea(.all)
}
}
#Preview {
ContentView()
}
メッシュグラディエント2
struct ContentView: View {
@State var colors: [Color] = [
.black, .black, .black,
.blue, .blue, .blue,
.green, .green, .green
]
var body: some View {
MeshGradient(
width: 3,
height: 3,
points: [
[0.0, 0.0], [0.4, 0.0], [1.0, 0.0],
[0.0, 0.5], [0.7, 0.4], [1.0, 0.5],
[0.0, 1.0], [0.3, 1.0], [1.0, 1.0]
], colors: colors)
.edgesIgnoringSafeArea(.all)
.onAppear {
startTimer()
}
}
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
withAnimation(.easeInOut(duration: 1.0)) {
colors = [
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1)),
Color(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1))
]
}
}
}
}
#Preview {
ContentView()
}
メッシュグラディエント2
struct ContentView: View {
@State private var isAnimating = false
@State private var colors: [Color] = [
.red, .purple, .indigo,
.orange, .white, .blue,
.yellow, .green, .mint
]
var body: some View {
MeshGradient(width: 3, height: 3, points: [
[0.0, 0.0], [0.5, 0], [1.0, 0.0],
[0.0, 0.5], [0.5, 0.5], [1.0, 0.5],
[0.0, 1.0], [0.5, 1.0], [1.0, 1.0]
], colors: colors,
smoothsColors: true,
colorSpace: .perceptual
)
.edgesIgnoringSafeArea(.all)
.onAppear {
startColorRotation()
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
isAnimating.toggle()
}
}
}
func startColorRotation() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
withAnimation(.easeInOut(duration: 1.0)) {
rotateColors()
}
}
}
func rotateColors() {
// 白以外の色を回転させる
colors = [
colors[8], colors[0], colors[1],
colors[7], colors[4], colors[2],
colors[6], colors[5], colors[3]
]
}
}
#Preview {
ContentView()
}
View transitions
ビューの表示と非表示の際のトランジションをカスタマイズします。
ビュートランジション1 | ビュートランジション2 |
---|---|
ビュートランジション1
struct ContentView: View {
@State var isVisible: Bool = true
var body: some View {
VStack {
GroupBox {
Toggle("Visible", isOn: $isVisible.animation())
}
Spacer()
if isVisible {
Avatar()
.transition(.scale.combined(with: .opacity))
}
Spacer()
}
.padding()
}
}
struct Avatar: View {
var body: some View {
Circle()
.fill(.red).opacity(0.2)
.overlay {
Image(systemName: "sun.dust.fill")
.resizable()
.scaledToFit()
.scaleEffect(0.5)
.foregroundStyle(.red)
}
.frame(width: 80, height: 80)
.compositingGroup()
}
}
#Preview {
ContentView()
}
ビュートランジション2
struct ContentView: View {
@State var isVisible: Bool = true
var body: some View {
VStack {
GroupBox {
Toggle("Visible", isOn: $isVisible.animation())
}
Spacer()
if isVisible {
Avatar()
.transition(Twirl())
}
Spacer()
}
.padding()
}
}
struct Avatar: View {
var body: some View {
Circle()
.fill(.red).opacity(0.2)
.overlay {
Image(systemName: "sun.dust.fill")
.resizable()
.scaledToFit()
.scaleEffect(0.5)
.foregroundStyle(.red)
}
.frame(width: 200, height: 200)
.compositingGroup()
}
}
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)
}
}
#Preview {
ContentView()
}
Text transitions
iOS 18で導入されたTextRenderer APIを使用して、テキストの行ごとのアニメーションを作成します。
テキストトランジション1 | テキストトランジション2 |
---|---|
テキストトランジション1
struct ContentView: View {
@State var isVisible: Bool = true
var body: some View {
VStack {
GroupBox {
Toggle("Visible", isOn: $isVisible.animation())
}
Spacer()
if isVisible {
let visualEffects = Text("ビジュアルエフェクト")
.customAttribute(EmphasisAttribute())
.foregroundStyle(.pink)
.bold()
Text("Build \(visualEffects) with SwiftUI 🧑💻")
.font(.system(.title, design: .rounded, weight: .semibold))
.frame(width: 350)
.transition(TextTransition())
}
Spacer()
}
.multilineTextAlignment(.center)
.padding()
}
}
struct EmphasisAttribute: TextAttribute {}
struct AppearanceEffectRenderer: TextRenderer, Animatable {
var elapsedTime: TimeInterval
var elementDuration: TimeInterval
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() {
let timeOffset = TimeInterval(index) * delay
let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))
var copy = context
draw(slice, at: elementTime, in: ©)
}
} else {
var copy = context
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) {
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)
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)
}
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 {
var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
self.flatMap { line in
line
}
}
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
if !transaction.disablesAnimations {
transaction.animation = .linear(duration: duration)
}
} body: { view in
view.textRenderer(renderer)
}
}
}
#Preview {
ContentView()
}
テキストトランジション2
struct ContentView: View {
@State var isVisible: Bool = true
@State var time: TimeInterval = 0.3
var body: some View {
VStack {
GroupBox {
HStack {
Text("Progress")
Slider(value: $time, in: 0 ... 0.8)
}
}
Spacer()
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)
.textRenderer(AppearanceEffectRenderer(elapsedTime: time, totalDuration: 0.8))
Spacer()
}
.multilineTextAlignment(.center)
.padding()
}
}
struct EmphasisAttribute: TextAttribute {}
struct AppearanceEffectRenderer: TextRenderer, Animatable {
var elapsedTime: TimeInterval
var elementDuration: TimeInterval
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() {
let timeOffset = TimeInterval(index) * delay
let elementTime = max(0, min(elapsedTime - timeOffset, elementDuration))
var copy = context
draw(slice, at: elementTime, in: ©)
}
} else {
var copy = context
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) {
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)
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)
}
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 {
var flattenedRuns: some RandomAccessCollection<Text.Layout.Run> {
self.flatMap { line in
line
}
}
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
if !transaction.disablesAnimations {
transaction.animation = .linear(duration: duration)
}
} body: { view in
view.textRenderer(renderer)
}
}
}
#Preview {
ContentView()
}
Metal shaders🤘
今回はじめてメタル入門💀しました。面白いですね〜。
メタルシェーダー1 | メタルシェーダー2 |
---|---|
メタルシェーダー1
struct ContentView: View {
@State var counter: Int = 0
@State var origin: CGPoint = .zero
var body: some View {
VStack {
Spacer()
Image("hawaii")
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.onPressingChanged { point in
if let point {
origin = point
counter += 1
}
}
.modifier(RippleEffect(at: origin, trigger: counter))
Spacer()
}
.padding()
}
}
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
}
}
}
#Preview {
ContentView()
}
メタルシェーダー2
struct ContentView: View {
@State var origin: CGPoint = .zero
@State var time: TimeInterval = 0.3
@State var amplitude: TimeInterval = 12
@State var frequency: TimeInterval = 15
@State var decay: TimeInterval = 8
var body: some View {
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("pipi")
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.modifier(RippleModifier(origin: origin, elapsedTime: time, duration: 2, amplitude: amplitude, frequency: frequency, decay: decay))
.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
}
}
}
#Preview {
ContentView()
}
まとめ
はじめてiPod touchを触ったときに感じた画面を触る楽しさを思い出しました。
当時のUIも今のUIに引けを取らないですね。下記動画のミュージックのアルバムを触ってるだけで楽しかったです。オススメです。
最後に
私の働いている会社で経験の有無を問わず採用を行っています。
興味のある方は是非カジュアル面談から応募してみてください!