先看效果:

一个基于 SwiftUI + UIKit 实现的优雅图片切换控件,支持呼吸式缩放动画和流畅的切换过渡效果
前言
在开发 iOS 应用时,我们经常需要展示图片轮播或切换效果。虽然市面上有很多成熟的图片轮播库,但有时候我们只需要一个简单、优雅且带有动画效果的图片切换控件。本文将介绍如何实现一个带有呼吸式缩放动画和平滑切换效果的图片展示控件。
✨ 核心特性
- 🎬 呼吸式缩放动画:图片在展示时会有类似 Ken Burns 效果的缓慢缩放动画
- 🔄 流畅切换过渡:切换图片时,旧图放大淡出,新图缩小淡入,视觉效果自然流畅
- 🌐 双重图片支持:同时支持网络图片和本地资源图片
- ⚡ 防抖机制:内置防抖逻辑,避免快速切换导致的动画混乱
- 🎨 SwiftUI 集成 :通过
UIViewRepresentable封装,可无缝集成到 SwiftUI 项目中
🎯 效果预览
控件在运行时具有以下动画效果:
- 待机状态:图片缓慢放大再缩小,循环播放(14秒一个周期)
- 切换动画 :
- 当前图片放大 + 淡出(0.2秒)
- 新图片从小到大 + 淡入(0.35秒)
- 切换完成后,新图片继续播放呼吸动画
🏗️ 实现原理
整体架构
控件由以下几个核心部分组成:
scss
AnimatedImageView (UIView)
├── currentImgView (当前显示的图片)
├── willShowImgView (即将显示的图片)
├── 缩放动画逻辑
├── 切换动画逻辑
└── 图片加载机制
关键技术点
1. 双 ImageView 架构
使用两个 UIImageView 来实现平滑的切换效果:
swift
private var currentImgView = UIImageView() // 当前显示的图片
private var willShowImgView = UIImageView() // 待切换的图片
这种设计让我们可以在切换时同时对两张图片应用不同的动画,从而实现自然的过渡效果。
2. 三种尺寸状态
为了实现缩放动画,控件定义了三种尺寸状态:
swift
private var originalBounds: CGRect = .zero // 原始尺寸
private var smallBounds: CGRect = .zero // 小尺寸(90%)
private var bigBounds: CGRect = .zero // 大尺寸(125%)
图片会在这些尺寸之间进行动画过渡:
swift
// 计算缩放尺寸
let sigleScale = 0.05
let doubleScale = 1.0 + sigleScale * 2
// 图片比视图大 10%,用于缩放动画时不露出边缘
let imgWidth = width * doubleScale
let imgHeight = height * doubleScale
3. 呼吸式缩放动画
使用 CABasicAnimation 实现无限循环的呼吸效果:
swift
private func addScaleAnimation() {
guard shouldContinueScaling else { return }
let anim = CABasicAnimation(keyPath: "bounds")
anim.fromValue = originalBounds
anim.toValue = bigBounds
anim.duration = scaleDuration // 14秒
anim.autoreverses = true // 自动反向
anim.repeatCount = .infinity // 无限循环
anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
currentImgView.layer.add(anim, forKey: "scaleLoop")
}
4. 切换动画组合
切换时同时执行四个动画:
swift
private func animateSwitch(completion: @escaping () -> Void) {
// 当前图片:放大 + 淡出
let shrinkAnim = CABasicAnimation(keyPath: "bounds")
shrinkAnim.fromValue = originalBounds
shrinkAnim.toValue = bigBounds
shrinkAnim.duration = switchDuration - 0.15
let fadeAnim = CABasicAnimation(keyPath: "opacity")
fadeAnim.fromValue = 1
fadeAnim.toValue = 0
fadeAnim.duration = switchDuration - 0.15
// 新图片:缩小到放大 + 淡入
let expandAnim = CABasicAnimation(keyPath: "bounds")
expandAnim.fromValue = smallBounds
expandAnim.toValue = originalBounds
expandAnim.duration = switchDuration
let unfadeAnim = CABasicAnimation(keyPath: "opacity")
unfadeAnim.fromValue = 0
unfadeAnim.toValue = 1.0
unfadeAnim.duration = switchDuration
// 使用 CATransaction 确保动画同步
CATransaction.begin()
CATransaction.setCompletionBlock {
// 切换完成后的清理工作
self.currentImgView.image = self.willShowImgView.image
// ... 重置状态
completion()
}
currentImgView.layer.add(shrinkAnim, forKey: "shrinkAnim")
currentImgView.layer.add(fadeAnim, forKey: "fadeAnim")
willShowImgView.layer.add(expandAnim, forKey: "expandAnim")
willShowImgView.layer.add(unfadeAnim, forKey: "unfadeAnim")
CATransaction.commit()
}
5. 防抖机制
为了避免快速切换造成的动画混乱,实现了防抖和队列机制:
swift
private var debounceWorkItem: DispatchWorkItem?
private let debounceDelay: TimeInterval = 0.15
private var pendingImages: [String] = []
func setImage(_ source: String) {
// 取消之前的防抖任务
debounceWorkItem?.cancel()
// 清空队列,只保留最新的图片
pendingImages.removeAll()
pendingImages.append(source)
// 延迟执行
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if !self.isSwitching {
self.showNextImage()
}
}
debounceWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: workItem)
}
6. 图片加载(支持网络和本地)
自动识别图片源类型并使用对应的加载方式:
swift
private func loadImage(from source: String, completion: @escaping (UIImage?) -> Void) {
if isNetworkURL(source) {
// 加载网络图片
guard let url = URL(string: source) else {
completion(nil)
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, let image = UIImage(data: data) else {
completion(nil)
return
}
completion(image)
}.resume()
}
else {
// 加载本地图片
DispatchQueue.global(qos: .userInitiated).async {
let image = UIImage(named: source)
completion(image)
}
}
}
private func isNetworkURL(_ string: String) -> Bool {
return string.hasPrefix("http://") || string.hasPrefix("https://")
}
💻 代码实现
核心控件:AnimatedImageView
完整的 AnimatedImageView.swift 实现:
swift
import UIKit
public final class AnimatedImageView: UIView {
private var switchDuration: CGFloat = 0.35 // 切换动画时长
private var scaleDuration: CGFloat = 14 // 缩放动画时长
private var currentImgView = UIImageView()
private var willShowImgView = UIImageView()
private var shouldContinueScaling = false
private var originalBounds: CGRect = .zero
private var smallBounds: CGRect = .zero
private var bigBounds: CGRect = .zero
private var pendingImages: [String] = []
var isSwitching = false
var firstImgSource = ""
var hasFirstImgSource = false
private var debounceWorkItem: DispatchWorkItem?
private let debounceDelay: TimeInterval = 0.15
/// 设置图片(支持网络URL或本地图片名称)
func setImage(_ source: String) {
if hasFirstImgSource == false {
firstImgSource = source
hasFirstImgSource = true
return
}
debounceWorkItem?.cancel()
pendingImages.removeAll()
pendingImages.append(source)
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if !self.isSwitching {
self.showNextImage()
}
}
debounceWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: workItem)
}
override init(frame: CGRect) {
super.init(frame: frame)
initImages()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
initImages()
}
// 创建图片视图
private func initImages() {
willShowImgView.contentMode = .scaleAspectFill
willShowImgView.clipsToBounds = true
addSubview(willShowImgView)
currentImgView.contentMode = .scaleAspectFill
currentImgView.clipsToBounds = true
addSubview(currentImgView)
}
// 设置图片大小
public override func layoutSubviews() {
super.layoutSubviews()
let sigleScale = 0.05
let doubleScale = 1.0 + sigleScale * 2
let width = bounds.width
let height = bounds.height
let x = -width * sigleScale
let y = -height * sigleScale
let imgWidth = width * doubleScale
let imgHeight = height * doubleScale
currentImgView.frame = CGRect(x: x, y: y, width: imgWidth, height: imgHeight)
willShowImgView.frame = currentImgView.frame
// 记录初始 bounds
if originalBounds == .zero {
originalBounds = currentImgView.frame
// 小尺寸(90%)
let smallScale = 0.10
smallBounds = originalBounds.insetBy(
dx: originalBounds.width * (smallScale / 2.0),
dy: originalBounds.height * (smallScale / 2.0)
)
// 大尺寸(125%)
let bigScale = 0.25
bigBounds = originalBounds.insetBy(
dx: -originalBounds.width * (bigScale / 2.0),
dy: -originalBounds.height * (bigScale / 2.0)
)
// 加载首张图片
if firstImgSource.isEmpty {
currentImgView.image = getDefaultImage()
startScaleAnimation()
} else {
loadImage(from: firstImgSource) { [weak self] image in
guard let self = self else { return }
DispatchQueue.main.async {
self.currentImgView.image = image ?? self.getDefaultImage()
self.startScaleAnimation()
}
}
}
}
}
// ... 其他方法(图片加载、动画等)
}
SwiftUI 封装
通过 UIViewRepresentable 将 UIKit 控件桥接到 SwiftUI:
swift
public struct SwiftUIAnimatedImageView: UIViewRepresentable {
let image: String
public func makeUIView(context: Context) -> AnimatedImageView {
let view = AnimatedImageView()
return view
}
public func updateUIView(_ uiView: AnimatedImageView, context: Context) {
uiView.setImage(image)
}
}
🚀 使用方法
基础使用
swift
import SwiftUI
struct ContentView: View {
@State private var currentIndex: Int = 1
var body: some View {
SwiftUIAnimatedImageView(image: "\(currentIndex)")
.ignoresSafeArea()
}
}
完整示例(带切换按钮)
swift
struct ContentView: View {
@State private var currentIndex: Int = 1
private let minIndex = 1
private let maxIndex = 5
var body: some View {
SwiftUIAnimatedImageView(image: String(currentIndex))
.ignoresSafeArea()
.overlay {
HStack(spacing: 30) {
// 上一张按钮
Button {
previousImage()
} label: {
HStack(spacing: 8) {
Image(systemName: "chevron.left")
Text("上一张")
}
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue.gradient)
)
}
// 下一张按钮
Button {
nextImage()
} label: {
HStack(spacing: 8) {
Text("下一张")
Image(systemName: "chevron.right")
}
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue.gradient)
)
}
}
}
}
private func previousImage() {
if currentIndex <= minIndex {
currentIndex = maxIndex
} else {
currentIndex -= 1
}
}
private func nextImage() {
if currentIndex >= maxIndex {
currentIndex = minIndex
} else {
currentIndex += 1
}
}
}
使用网络图片
swift
SwiftUIAnimatedImageView(image: "https://example.com/image.jpg")
🎨 自定义配置
你可以根据需求调整以下参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
switchDuration |
切换动画时长 | 0.35秒 |
scaleDuration |
呼吸缩放动画时长 | 14秒 |
debounceDelay |
防抖延迟 | 0.15秒 |
smallScale |
小尺寸缩放比例 | 0.10 (90%) |
bigScale |
大尺寸缩放比例 | 0.25 (125%) |
修改示例:
swift
// 在 AnimatedImageView 中
private var switchDuration: CGFloat = 0.5 // 切换更慢
private var scaleDuration: CGFloat = 10 // 呼吸更快
📝 技术要点总结
- 动画分层:将呼吸动画和切换动画分离,互不干扰
- 状态管理 :使用
isSwitching标志避免动画冲突 - 内存优化 :使用
weak self避免循环引用 - 视觉连续性:图片比容器大 10%,缩放时不露边
- 时序控制 :使用
CATransaction确保动画同步 - 用户体验:防抖机制避免快速点击造成的混乱
💡 进阶优化建议
- 图片缓存:集成 SDWebImage 或 Kingfisher 提升网络图片加载性能
- 自定义动画:开放动画参数,允许外部自定义动画效果
- 手势支持:添加左右滑动手势切换图片
- 预加载:提前加载下一张图片,减少等待时间
- 性能监控:添加帧率监控,确保动画流畅度
🎉 总结
本文实现的图片切换控件具有以下优势:
- ✅ 优雅的视觉效果:呼吸式动画 + 平滑切换
- ✅ 良好的性能:使用 CAAnimation,GPU 加速
- ✅ 易于集成:SwiftUI 友好,一行代码即可使用
- ✅ 灵活可扩展:支持本地和网络图片,易于定制
如果你的项目需要一个简洁但不失优雅的图片展示控件,不妨试试这个方案。代码简洁,效果出众,相信能为你的 App 增色不少!
相关技术栈:SwiftUI、UIKit、Core Animation、CABasicAnimation、UIViewRepresentable
适用场景:背景图片展示、产品轮播、引导页、登录页背景等
源码地址 :FMAnimatedImageView
👍 如果觉得有帮助,欢迎点赞收藏!有问题欢迎在评论区讨论~