You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何修复iOS阅读器中ImageView的X轴移动动画跳转问题?

iOS阅读器页面切换动画异常修复方案

问题说明

开发的阅读器类iOS App支持触摸屏幕左右边缘切换上下页,页面包含全屏且单侧超出20pt的图片,根据JSON配置的imageDirection属性,图片会从0pt位置右移20pt或左移-20pt。当前存在的问题:等待图片动画完成后翻页一切正常,但未等动画结束就触发翻页时,当前页面图片会快速跳回初始位置,再显示下一页动画,需要实现无跳转的平滑切换效果。

核心代码梳理

Reader Controller 核心逻辑

class ReaderController: UIViewController {
    var pagesData = [PageData]()
    var imageArray: [UIImage] = []
    var index = Int()
    var pageIndex: Int = -1
    
    let pageContainer: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    let pageViews: [PageLayout] = {
        let view = [PageLayout(), PageLayout()]
        view[0].translatesAutoresizingMaskIntoConstraints = false
        view[1].translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        loadImages()
        setupViews()
        setupConstraints()
        pageViews[0].index = index
        pageViews[0].imageArray = imageArray
        pageViews[1].index = index
        pageViews[1].imageArray = imageArray
        pageViews[0].pageIndex = pageIndex
        pageViews[1].pageIndex = pageIndex
        pageTransition(animated: false, direction: "fromRight")
    }
    
    func setupViews() {
        pageContainer.addSubview(pageViews[0])
        pageContainer.addSubview(pageViews[1])
        view.addSubview(pageContainer)
    }
    
    func setupConstraints() {
        pageContainer.topAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
        pageContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0).isActive = true
        pageContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
        pageContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
        pageViews[0].topAnchor.constraint(equalTo: pageContainer.topAnchor).isActive = true
        pageViews[0].bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor).isActive = true
        pageViews[0].leadingAnchor.constraint(equalTo: pageContainer.leadingAnchor).isActive = true
        pageViews[0].trailingAnchor.constraint(equalTo: pageContainer.trailingAnchor).isActive = true
        pageViews[1].topAnchor.constraint(equalTo: pageContainer.topAnchor).isActive = true
        pageViews[1].bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor).isActive = true
        pageViews[1].leadingAnchor.constraint(equalTo: pageContainer.leadingAnchor).isActive = true
        pageViews[1].trailingAnchor.constraint(equalTo: pageContainer.trailingAnchor).isActive = true
    }
    
    func loadData(fileName: Any) -> PagesData {
        var url = NSURL()
        url = Bundle.main.url(forResource: "text", withExtension: "json")! as NSURL
        let data = try! Data(contentsOf: url as URL)
        let person = try! JSONDecoder().decode(PagesData.self, from: data)
        return person
    }
    
    func loadImages() {
        for imageIndex in 1...18 { imageArray.append(UIImage(named: "page\(imageIndex)")!) }
    }
        
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            if(touch.view == pageViews[0] || touch.view == pageViews[1]) {
                let location = touch.location(in: view.self)
                if view.safeAreaInsets.left > 30 {
                    if (location.x > self.view.frame.size.width - (view.safeAreaInsets.left * 1.5)) {
                        pageTransition(animated: true, direction: "fromRight")
                    } else if (location.x < (view.safeAreaInsets.left * 1.5)) {
                        pageTransition(animated: true, direction: "fromLeft")
                    }
                }
                else {
                    if (location.x > self.view.frame.size.width - 40) {
                        pageTransition(animated: true, direction: "fromRight")
                    } else if (location.x < 40) {
                        pageTransition(animated: true, direction: "fromLeft")
                    }
                }
            }
        }
    }
    
    func pageTransition(animated: Bool, direction: String) {
        let result = loadData(fileName: pagesData)
        switch direction {
        case "fromRight":
            pageIndex += 1
        case "fromLeft":
            pageIndex -= 1
        default: break
        }
        pageViews[0].pageIndex = pageIndex
        pageViews[1].pageIndex = pageIndex
        if pageIndex <= -1 {
            pageIndex = 0
        } else if pageIndex >= result.pagesData.count {
            pageIndex = result.pagesData.count - 1
            finishReading()
        } else {
            let fromView = pageViews[0].isHidden ? pageViews[1] : pageViews[0]
            let toView = pageViews[0].isHidden ? pageViews[0] : pageViews[1]
            toView.configure(theData: result.pagesData[pageIndex])
            if animated {
                toView.isHidden = false
                let animation = CATransition()
                animation.duration = 0
                animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
                animation.isRemovedOnCompletion = true
                animation.subtype = direction
                let tapAnimationType = "fade"
                switch tapAnimationType {
                case "pageCurl": animation.type = "pageCurl"
                case "fade": animation.type = "fade"
                case "moveIn": animation.type = "moveIn"
                case "push": animation.type = "push"
                case "reveal": animation.type = "reveal"
                default: animation.type = "fade" }
                self.pageContainer.layer.add(animation, forKey: "pageAnimation")
                self.pageContainer.bringSubview(toFront: toView)
            }
            else {
                fromView.isHidden = true
                toView.isHidden = false
            }
        }
    }
}

Page Layout 视图布局

class PageLayout: UIView {
        
    var imageArray: [UIImage] = []
    var index = Int()
    var pageIndex = Int()
    
    var imageDirection = ""
    
    var imageViewTopConstraint = NSLayoutConstraint()
    var imageViewBottomConstraint = NSLayoutConstraint()
    var imageViewLeadingConstraint = NSLayoutConstraint()
    var imageViewTrailingConstraint = NSLayoutConstraint()
    
    private let imageView: UIImageView = {
        let image = UIImageView()
        image.contentMode = .scaleAspectFill
        image.translatesAutoresizingMaskIntoConstraints = false
        return image
    }()
    
    private let textLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    let screenWidth = UIScreen.main.bounds.size.width
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("Not happening")
    }
    
    func setupViews() {
        addSubview(imageView)
        addSubview(textLabel)
    }
    
    func setupConstraints() {
        let scenes = UIApplication.shared.connectedScenes
        let windowScene = scenes.first as? UIWindowScene
        let window = windowScene?.windows.first
        let safeArea = window?.safeAreaInsets
        textLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: safeArea!.left+16.0).isActive = true
        textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -safeArea!.right-16.0).isActive = true
        textLabel.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        textLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        removeConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint,
                           imageViewTrailingConstraint])
        switch imageDirection {
        case "left":
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -20.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0)
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            imageView.layer.removeAllAnimations()
            imageView.transform = CGAffineTransformTranslate(.identity, 0, 0)
            UIView.animate(withDuration: 4.0, delay: 0, animations: {
                self.imageView.transform = CGAffineTransformTranslate(.identity, 20, 0)
            }, completion: nil)
        case "right":
            imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0)
            imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0)
            imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0)
            imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 20.0)
            addConstraints([imageViewTopConstraint, imageViewBottomConstraint, imageViewLeadingConstraint, imageViewTrailingConstraint])
            imageView.layer.removeAllAnimations()
            imageView.transform = CGAffineTransformTranslate(.identity, 0, 0)
            UIView.animate(withDuration: 4.0, delay: 0, animations: {
                self.imageView.transform = CGAffineTransformTranslate(.identity, -20, 0)
            }, completion: nil)
        default: break
        }
    }
        
    func configure(theData: PageData) {
        textLabel.text = theData.textData
        imageDirection = theData.imageDirection
        imageView.image = imageArray[pageIndex]
        setupConstraints()
    }
}

数据结构与JSON配置

struct PagesData: Decodable {
    var pagesData: [PageData]
}

struct PageData: Decodable {
    let textData, imageDirection: String
}
{
    "pagesData" : [
        {"textData" : "1","imagePosition" : "left"},
        {"textData" : "2","imagePosition" : "left"},
        {"textData" : "3","imagePosition" : "right"},
        {"textData" : "4","imagePosition" : "right"}
    ]
}

问题根源

  1. 强制重置动画状态:每次调用setupConstraints时,都会执行imageView.layer.removeAllAnimations()和imageView.transform = CGAffineTransformTranslate(.identity, 0, 0),直接终止当前动画并将图片重置到初始位置,导致翻页时出现跳转。
  2. JSON字段不匹配:Swift结构体中使用imageDirection,但JSON里是imagePosition,会导致配置解析失败,图片动画逻辑无法触发。
  3. 翻页逻辑冗余:每次翻页都重新加载JSON数据,既浪费资源也可能导致状态不一致。

修复方案

1. 移除强制重置动画代码

修改PageLayout的setupConstraints方法,删除强制终止动画和重置位置的代码,同时记录当前动画进度,让新页面动画基于当前状态延续:

class PageLayout: UIView {
    // 新增:记录当前图片偏移量
    private var currentOffset: CGFloat = 0
    
    // ... 原有代码不变 ...
    
    func setupConstraints() {
        // ... 文本约束代码不变 ...
        
        switch imageDirection {
        case "left":
            // ... 图片约束代码不变 ...
            // 移除重置动画和位置的代码
            let targetOffset: CGFloat = 20
            // 计算剩余动画时长
            let remainingTime = 4.0 * (1 - currentOffset / targetOffset)
            UIView.animate(withDuration: remainingTime, delay: 0, animations: {
                self.imageView.transform = CGAffineTransformTranslate(.identity, targetOffset, 0)
            }, completion: { _ in
                self.currentOffset = targetOffset
            })
        case "right":
            // ... 图片约束代码不变 ...
            let targetOffset: CGFloat = -20
            let remainingTime = 4.0 * (1 - abs(self.currentOffset) / abs(targetOffset))
            UIView.animate(withDuration: remainingTime, delay: 0, animations: {
                self.imageView.transform = CGAffineTransformTranslate(.identity, targetOffset, 0)
            }, completion: { _ in
                self.currentOffset = targetOffset
            })
        default: break
        }
    }
        
    func configure(theData: PageData) {
        textLabel.text = theData.textData
        imageDirection = theData.imageDirection
        imageView.image = imageArray[pageIndex]
        // 保存当前偏移量,避免重置
        currentOffset = imageView.transform.tx
        setupConstraints()
    }
    
    // 页面隐藏时保存动画进度
    override func willMove(toSuperview newSuperview: UIView?) {
        super.willMove(toSuperview: newSuperview)
        if newSuperview == nil {
            currentOffset = imageView.transform.tx
            imageView.layer.removeAllAnimations()
        }
    }
}

2. 修正JSON字段匹配问题

将JSON中的imagePosition改为imageDirection,或者在Swift结构体中添加编码映射:

struct PageData: Decodable {
    let textData, imageDirection: String
    
    enum CodingKeys: String, CodingKey {
        case textData
        case imageDirection = "imagePosition"
    }
}

3. 优化翻页逻辑,避免重复加载数据

在ReaderController的viewDidLoad中一次性加载JSON数据,避免翻页时重复读取:

class ReaderController: UIViewController {
    private var allPagesData: PagesData!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        loadImages()
        // 提前加载所有页面数据
        allPagesData = loadData()
        // ... 其他初始化代码不变 ...
    }
    
    // 修改加载数据方法,移除无用参数
    func loadData() -> PagesData {
        let url = Bundle.main.url(forResource: "text", withExtension: "json")!
        let data = try! Data(contentsOf: url)
        return try! JSONDecoder().decode(PagesData.self, from: data)
    }
    
    func pageTransition(animated: Bool, direction: String) {
        // ... 页码计算逻辑不变 ...
        if pageIndex <= -1 {
            pageIndex = 0
        } else if pageIndex >= allPagesData.pagesData.count {
            pageIndex = allPagesData.pagesData.count - 1
            finishReading()
        } else {
            let fromView = pageViews[0].isHidden ? pageViews[1] : pageViews[0]
            let toView = pageViews[0].isHidden ? pageViews[0] : pageViews[1]
            toView.configure(theData: allPagesData.pagesData[pageIndex])
            if animated {
                toView.isHidden = false
                let animation = CATransition()
                // 设置合理的过渡时长,之前为0会导致无动画
                animation.duration = 0.3
                // ... 其他动画配置不变 ...
                self.pageContainer.layer.add(animation, forKey: "pageAnimation")
                self.pageContainer.bringSubview(toFront: toView)
                // 隐藏旧页面时触发进度保存
                fromView.isHidden = true
            }
            else {
                fromView.isHidden = true
                toView.isHidden = false
            }
        }
    }
}

修复效果

完成上述修改后,即使在图片动画未完成时触发翻页,当前页面图片不会跳回初始位置,而是直接隐藏;新页面的动画会基于配置的目标偏移量正常执行,实现完全平滑的页面切换效果。

内容的提问来源于stack exchange,提问作者Alex Smith

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 07:50:36