SwiftUI 支持呼吸动画的图片切换小控件

先看效果:

一个基于 SwiftUI + UIKit 实现的优雅图片切换控件,支持呼吸式缩放动画和流畅的切换过渡效果

前言

在开发 iOS 应用时,我们经常需要展示图片轮播或切换效果。虽然市面上有很多成熟的图片轮播库,但有时候我们只需要一个简单、优雅且带有动画效果的图片切换控件。本文将介绍如何实现一个带有呼吸式缩放动画和平滑切换效果的图片展示控件。

✨ 核心特性

  • 🎬 呼吸式缩放动画:图片在展示时会有类似 Ken Burns 效果的缓慢缩放动画
  • 🔄 流畅切换过渡:切换图片时,旧图放大淡出,新图缩小淡入,视觉效果自然流畅
  • 🌐 双重图片支持:同时支持网络图片和本地资源图片
  • 防抖机制:内置防抖逻辑,避免快速切换导致的动画混乱
  • 🎨 SwiftUI 集成 :通过 UIViewRepresentable 封装,可无缝集成到 SwiftUI 项目中

🎯 效果预览

控件在运行时具有以下动画效果:

  1. 待机状态:图片缓慢放大再缩小,循环播放(14秒一个周期)
  2. 切换动画
    • 当前图片放大 + 淡出(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    // 呼吸更快

📝 技术要点总结

  1. 动画分层:将呼吸动画和切换动画分离,互不干扰
  2. 状态管理 :使用 isSwitching 标志避免动画冲突
  3. 内存优化 :使用 weak self 避免循环引用
  4. 视觉连续性:图片比容器大 10%,缩放时不露边
  5. 时序控制 :使用 CATransaction 确保动画同步
  6. 用户体验:防抖机制避免快速点击造成的混乱

💡 进阶优化建议

  1. 图片缓存:集成 SDWebImage 或 Kingfisher 提升网络图片加载性能
  2. 自定义动画:开放动画参数,允许外部自定义动画效果
  3. 手势支持:添加左右滑动手势切换图片
  4. 预加载:提前加载下一张图片,减少等待时间
  5. 性能监控:添加帧率监控,确保动画流畅度

🎉 总结

本文实现的图片切换控件具有以下优势:

  • 优雅的视觉效果:呼吸式动画 + 平滑切换
  • 良好的性能:使用 CAAnimation,GPU 加速
  • 易于集成:SwiftUI 友好,一行代码即可使用
  • 灵活可扩展:支持本地和网络图片,易于定制

如果你的项目需要一个简洁但不失优雅的图片展示控件,不妨试试这个方案。代码简洁,效果出众,相信能为你的 App 增色不少!


相关技术栈:SwiftUI、UIKit、Core Animation、CABasicAnimation、UIViewRepresentable

适用场景:背景图片展示、产品轮播、引导页、登录页背景等

源码地址FMAnimatedImageView


👍 如果觉得有帮助,欢迎点赞收藏!有问题欢迎在评论区讨论~

相关推荐
东坡肘子1 天前
惊险但幸运,两次!| 肘子的 Swift 周报 #0109
人工智能·swiftui·swift
汉秋2 天前
SwiftUI动画之使用 navigationTransition(.zoom) 实现 Hero 动画
ios·swiftui
我唔知啊6 天前
SwiftUI 无限循环轮播图 支持手动控制
ios·swiftui
汉秋7 天前
SwiftUI布局之AnchorPreferences
swiftui
东坡肘子8 天前
Swift 官方发布 Android SDK | 肘子的 Swift 周报 #0108
android·swiftui·swift
疯笔码良10 天前
【IOS开发】SwiftUI + OpenCV实现图片的简单处理(一)
opencv·ios·swiftui
大熊猫侯佩12 天前
【大话码游之 Observation 传说】上集:月光宝盒里的计数玄机
swiftui·swift·weak·observable·self·引用循环·observations
齐行超13 天前
SwiftUI NavigatorStack 导航容器
ios·swiftui·navigationstack·navigationpath·navigationlink
东坡肘子15 天前
去 Apple Store 修手机 | 肘子的 Swift 周报 #0107
swiftui·swift·apple