如何修复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"} ] }
问题根源
- 强制重置动画状态:每次调用
setupConstraints时,都会执行imageView.layer.removeAllAnimations()和imageView.transform = CGAffineTransformTranslate(.identity, 0, 0),直接终止当前动画并将图片重置到初始位置,导致翻页时出现跳转。 - JSON字段不匹配:Swift结构体中使用
imageDirection,但JSON里是imagePosition,会导致配置解析失败,图片动画逻辑无法触发。 - 翻页逻辑冗余:每次翻页都重新加载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
相关产品推荐
相关产品推荐

