我们常见的一些广告位、图片轮播都是可以无限轮播的,以前参考文章 iOS开发系列–无限循环的图片浏览器,自己也在项目中实际应用了,现在用Swift重新实现一遍,发现代码原来可以更加精简,并且轮播手机相册的所有图片时遇到了一些问题,所以记录一下,帮助需要用到的同学快速实现这个小功能。
先看一下效果图,这里分为本地图片、网络图片、相册三个部分来实现,首先看一下本地效果。
其实本地的实现是最简单的,相册的实现更加复杂一些,因为相册图片牵扯到 PHCachingImageManager
的异步回调获取图片,需要单独去处理。网络图片如果使用 Kingfisher
来处理,那么就和本地图片一样了,但是如果使用系统的方法,即使用
let url = URL(string: urlString)
let imageData = try! Data(contentsOf: url)
let image = UIImage(data: imageData)
来获取图片,会有一些问题。下面会有单独说明。
这里使用的 Xcode8
、 Swift3
。
原理分析
这里一共使用了3个 UIImageView
,然后滑动结束时重置 ScrollView
的偏移值。
代码实现-首页
首先是项目简单地文件目录。
然后是 Main.storyboard
文件。这里能用故事版画的控件我都是拒绝手写的。
这里有一点需要注意,获取相册信息时,需要在 info.plist
文件中添加字段,并且是必须添加,否则直接报错。
首页的代码并没有多少要说明的,就是一些基本的准备工作,全部代码如下(部分说明都在注释中:
import UIKit
import Photos
class ViewController: UIViewController {
// 本地图片
fileprivate var localImages: [UIImage]! {
var newImages = [UIImage]()
for index in 1 ... 4 {
newImages.append(UIImage(named: "\(index).jpg")!)
}
return newImages
}
// 网络图片链接
fileprivate var netImageUrls = ["http://photocdn.sohu.com/20141225/Img407278780.jpg",
"http://imgsrc.baidu.com/forum/w=580/sign=72d55a713b6d55fbc5c6762e5d234f40/85950d338744ebf84e0e5290dff9d72a6159a713.jpg",
"http://p5.image.hiapk.com/uploads/allimg/150210/7730-150210155949-50.jpg",
"http://file26.mafengwo.net/M00/80/AB/wKgB4lL5tX6AZGB6ABBgnBkJakw73.jpeg"]
// 相册图片
fileprivate var allAssets = [PHAsset]()
override func viewDidLoad() {
super.viewDidLoad()
getAlbumAssets { (assets) in
self.allAssets = assets
}
}
/// 获取系统相册数据
///
/// - parameter callback: 回调结果
fileprivate func getAlbumAssets(callback: @escaping ([PHAsset]) -> Void) {
PHPhotoLibrary.requestAuthorization { (status) in
guard status == PHAuthorizationStatus.authorized else {
print("获取相册权限后再进行操作")
return
}
PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumUserLibrary, options: nil).enumerateObjects({ (collection, index, flag) in
let result = PHAsset.fetchAssets(in: collection, options: nil)
result.enumerateObjects({ (asset, index, flag) in
self.allAssets.append(asset)
})
callback(self.allAssets)
})
}
}
fileprivate let localImageSegue = "localImageSegue"
fileprivate let netUrlImageSegue = "netUrlImageSegue"
fileprivate let assetImageSegue = "assetImageSegue"
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let previewVC = segue.destination as? PreviewViewController else {
return
}
switch segue.identifier! {
case localImageSegue:
previewVC.getDate(resourceArray: localImages, .localImage)
case netUrlImageSegue:
previewVC.getDate(resourceArray: netImageUrls, .netImageUrl)
case assetImageSegue:
previewVC.getDate(resourceArray: allAssets, .asset)
default:
break
}
}
}
我把主要需要注意的点和注释都写在代码里了,参考时只需要注意一些 storyboard
的连接控件即可。因为我在Demo中需要处理 UIImage
、String
、 PHAsset
三种类型,所以在接收方法里用了 [Any]
,导致处理的时候要去分别判断。只是单个数据类型的话并不需要这么麻烦。
下面这些是实现的基本代码,后面关于网络图片和相册图片都是在这个页面里添加对应的方法。
import UIKit
import Photos
enum DataType {
case localImage
case netImageUrl
case asset
}
class PreviewViewController: UIViewController, UIScrollViewDelegate {
// MARK: - Properties
@IBOutlet weak var imageScrollView: UIScrollView!
fileprivate let mScreenWidth = UIScreen.main.bounds.width
fileprivate let mScreenHeight = UIScreen.main.bounds.height
fileprivate var leftImageView: UIImageView!
fileprivate var centerImageView: UIImageView!
fileprivate var rightImageView: UIImageView!
fileprivate var currentIndex = 0
fileprivate var resource: [Any]!
fileprivate var dataType: DataType!
// MARK: - 开放接口
internal func getDate(resourceArray: [Any], _ type: DataType) {
dataType = type
resource = resourceArray
}
// MARK: - LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
// 初始化控件
setupScrollView()
// 展示
prepareToShowPhotos()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.isNavigationBarHidden = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.isNavigationBarHidden = false
}
// 隐藏StatusBar
override var prefersStatusBarHidden: Bool {
return true
}
// MARK: - Private Methods
/// 初始化ScrollView
fileprivate func setupScrollView() {
leftImageView = prepareShowImageView(imageViewPosition: .leftImageView)
centerImageView = prepareShowImageView(imageViewPosition: .centerImageView)
rightImageView = prepareShowImageView(imageViewPosition: .rightImageView)
// FIXME: 测试
// leftImageView.backgroundColor = UIColor.red
// centerImageView.backgroundColor = UIColor.green
// rightImageView.backgroundColor = UIColor.orange
imageScrollView.contentSize = CGSize(width: mScreenWidth * 3,
height: 0)
imageScrollView.showsHorizontalScrollIndicator = false
imageScrollView.contentOffset = CGPoint(x: mScreenWidth, y: 0)
imageScrollView.isPagingEnabled = true
}
// 位置
fileprivate enum ShowImageViewPosition {
case leftImageView
case centerImageView
case rightImageView
}
/// 统一设置展示控件
///
/// - parameter imageViewPosition: 左、中、右位置
///
/// - returns: 设置完成UIImageView
fileprivate func prepareShowImageView(imageViewPosition: ShowImageViewPosition) -> UIImageView {
let imageView = UIImageView()
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFit
imageView.frame = view.bounds
imageView.backgroundColor = UIColor.black
switch imageViewPosition {
case .leftImageView:
imageView.frame.origin.x = 0
case .centerImageView:
imageView.frame.origin.x = mScreenWidth
case .rightImageView:
imageView.frame.origin.x = 2 * mScreenWidth
}
imageScrollView.addSubview(imageView)
return imageView
}
/// 展示图片
fileprivate func prepareToShowPhotos() {
// 如果仅仅有一张
if resource.count == 1 {
imageScrollView.contentSize = view.bounds.size
}
switch dataType! {
case .localImage:
setLocalImages()
case .netImageUrl:
setNetImages()
case .asset:
setPHAssetImages()
}
}
/// 展示本地图片
fileprivate func setLocalImages() {
leftImageView.image = resource[(currentIndex + resource.count - 1) % resource.count] as? UIImage
centerImageView.image = resource[currentIndex % resource.count] as? UIImage
rightImageView.image = resource[(currentIndex + resource.count + 1) % resource.count] as? UIImage
}
/// 展示网络图片
fileprivate func setNetImages() {
}
/// 展示相册图片
fileprivate func setPHAssetImages() {
}
// MARK: - UIScrollView Delegate
/// 开始拖拽ScrollView时
fileprivate var originalOffsetX: CGFloat = 0
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
originalOffsetX = scrollView.contentOffset.x
}
/// ScrollView 惯性结束
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 当滑动幅度不够时,保留ScrollView的原生特性
if originalOffsetX == scrollView.contentOffset.x {
return
}
if currentIndex == 0 {
currentIndex = resource.count
}
// 滑动时,更改显示
if originalOffsetX < scrollView.contentOffset.x {
currentIndex += 1
} else {
currentIndex -= 1
}
// 更改偏移,重新展示
imageScrollView.setContentOffset(CGPoint(x: mScreenWidth, y: 0), animated: false)
switch dataType! {
case .localImage:
setLocalImages()
case .netImageUrl:
setNetImages()
case .asset:
setPHAssetImages()
}
}
// MARK: - Outlet Actions
@IBAction func backAction(_ sender: UIButton) {
navigationController!.popViewController(animated: true)
}
}
核心代码到这里就结束了。上面的代码直接复制到自己新建的demo 中就是可以直接运行的。下面则是对于网络图片和相册图片的扩展,因为都牵扯到异步处理,所以都是对上面代码的补充。
首先说不使用第三方框架,仅仅使用下面主要代码获取网络图片时,
let url = URL(string: urlString)
let imageData = try! Data(contentsOf: url)
let image = UIImage(data: imageData)
如果放在主线程去做,会造成很严重的卡顿,如果开启线程,然后单独下载图片,会因为异步的问题导致保存图片的数组内部重复保存。如果开启线程依赖,也会有问题。总之,尝试失败,好在不是轮播重点,暂时pass。有好的思路的时候再来补充。
利用 cocoapods
导入第三方图片框架 Kingfisher
,至于 cocoapods
的使用,网上有很多非常完善的教程。那么问题处理就简单很多了,完全像本地图片一样了。
效果图:
首先,定义默认图片属性
fileprivate var placeHodlerImage = UIImage(named: "defaultLoad.png")
然后,完善方法 setNetImages()
:
/// 展示网络图片
fileprivate func setNetImages() {
let leftUrl = URL(string: (self.resource[(self.currentIndex + self.resource.count - 1) % self.resource.count] as? String)!)
leftImageView.kf.setImage(with: leftUrl, placeholder:placeHodlerImage, options: nil, progressBlock: nil, completionHandler: nil)
let centerUrl = URL(string: (self.resource[self.currentIndex % self.resource.count] as? String)!)
centerImageView.kf.setImage(with: centerUrl, placeholder:placeHodlerImage, options: nil, progressBlock: nil, completionHandler: nil)
let rightUrl = URL(string: (self.resource[(self.currentIndex + self.resource.count + 1) % self.resource.count] as? String)!)
rightImageView.kf.setImage(with: rightUrl, placeholder:placeHodlerImage, options: nil, progressBlock: nil, completionHandler: nil)
}
带有瑕疵的方案,之所以要说这一点,因为以前我就一直使用的这种方式,遇到了一些坑,直到最后解决。
首先,添加相册元数据 PHAsset
转化为 UIImage
的方法:
/// 将Asset转化为UIImage
///
/// - parameter singleAsset: 元数据
///
/// - returns: image
fileprivate func transformAssetToImage(singleAsset: PHAsset,_ callback: @escaping (UIImage) -> Void) {
PHCachingImageManager.default().requestImage(for: singleAsset, targetSize: view.bounds.size, contentMode: .aspectFit, options: nil) { (requestImage, nil) in
callback(requestImage!)
}
}
然后,完善方法 setPHAssetImages()
:
/// 展示相册图片
fileprivate func setPHAssetImages() {
// 左
let leftAsset = resource[(currentIndex + resource.count - 1) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: leftAsset) { (requestImage) in
DispatchQueue.main.async {
self.leftImageView.image = requestImage
}
}
// 中
let centerAsset = resource[currentIndex % resource.count] as! PHAsset
transformAssetToImage(singleAsset: centerAsset) { (requestImage) in
DispatchQueue.main.async {
self.centerImageView.image = requestImage
}
}
// 右
let rightAsset = resource[(currentIndex + 1) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: rightAsset) { (requestImage) in
DispatchQueue.main.async {
self.rightImageView.image = requestImage
}
}
}
最后,看效果:
可以很明显的看到图片的抖动现象,这个问题其实现在我也不确定是什么原因造成的,只是结合自己后来的尝试找到了解决方法,算是以结果倒推原因。
改善版。为了避免重复的使用方法 PHCachingImageManager
来解析图片,建立一个临时图片数组,存放解析到的图片,然后从从图片数组中读取图片。
首先,创建一个临时图片数组:
/// 将相册图片缓存到内存中
fileprivate var cacheImages: [UIImage]!
然后,在接收方法中初始化:
// MARK: - 开放接口
internal func getDate(resourceArray: [Any], _ type: DataType) {
dataType = type
resource = resourceArray
cacheImages = Array(repeating:placeHodlerImage!, count: resource.count)
}
再然后,完善方法 setPHAssetImages()
。定义一个 标识数组,确保相册的每一项 PHAsset
都已经被转化为 UIImage
。只有第一次解析图片时,才会直接将图片放置到左中右三个 UIImageView
上,然后开始滑动 ScrollView
时,再次解析图片, ScrollView
惯性结束时, 则是从缓存中读取。
设置标识数组:
/// asset 是否完全转化为 Image 的标识
fileprivate var transformAssetToImageFlags = [Int]()
完善 setPHAssetImages()
方法。这里添加了一个 isFirst
布尔参数,为了判断是否是第一次进来解析图片。
/// 展示相册图片
fileprivate func setPHAssetImages(isFirst: Bool) {
let index = currentIndex % resource.count
if transformAssetToImageFlags.count == 0 {
// 第一次执行
transformAssetToImageFlags.append(index)
}
// 判断是否已经有相同图片添加到缓存数组
var isAdd = false
transformAssetToImageFlags.forEach { (flag) in
if flag == index {
isAdd = true
}
}
if !isAdd {
transformAssetToImageFlags.append(index)
}
// 左
let leftAsset = resource[(currentIndex + resource.count - 1) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: leftAsset) { (requestImage) in
if isFirst {
self.leftImageView.image = requestImage
}
self.cacheImages[(self.currentIndex + self.resource.count - 1) % self.resource.count] = requestImage
}
// 中
let centerAsset = resource[currentIndex % resource.count] as! PHAsset
transformAssetToImage(singleAsset: centerAsset) { (requestImage) in
if isFirst {
self.centerImageView.image = requestImage
}
self.cacheImages[self.currentIndex % self.resource.count] = requestImage
}
// 右
let rightAsset = resource[(currentIndex + 1) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: rightAsset) { (requestImage) in
if isFirst {
self.rightImageView.image = requestImage
}
self.cacheImages[(self.currentIndex + 1) % self.resource.count] = requestImage
}
}
修改第一次获取图片的方法:
/// 展示图片
fileprivate func prepareToShowPhotos() {
// 如果仅仅有一张
if resource.count == 1 {
imageScrollView.contentSize = view.bounds.size
}
switch dataType! {
case .localImage:
setLocalImages()
case .netImageUrl:
setNetImages()
case .asset:
setPHAssetImages(isFirst: true)
}
}
修改 ScrollView
拖动时的方法:
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
originalOffsetX = scrollView.contentOffset.x
switch dataType! {
case .localImage:
break
case .netImageUrl:
break
case .asset:
// 判断是否所有图片都已经转化
if transformAssetToImageFlags.count == resource.count {
break
}
setPHAssetImages(isFirst: false)
}
}
添加读取图片的方法:
/// 从缓存的图片数组中读取数据
fileprivate func showImageFromCacheImages() {
leftImageView.image = cacheImages[(self.currentIndex - 1) % self.resource.count]
centerImageView.image = cacheImages[self.currentIndex % self.resource.count]
rightImageView.image = cacheImages[(self.currentIndex + 1) % self.resource.count]
}
修改 ScrollView
惯性结束的方法:
/// ScrollView 惯性结束
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 当滑动幅度不够时,保留ScrollView的原生特性
if originalOffsetX == scrollView.contentOffset.x {
return
}
if currentIndex == 0 {
currentIndex = resource.count
}
// 滑动时,更改显示
if originalOffsetX < scrollView.contentOffset.x {
currentIndex += 1
} else {
currentIndex -= 1
}
// 更改偏移,重新展示
imageScrollView.setContentOffset(CGPoint(x: mScreenWidth, y: 0), animated: false)
switch dataType! {
case .localImage:
setLocalImages()
case .netImageUrl:
setNetImages()
case .asset:
showImageFromCacheImages()
}
}
展示效果:
其实也就是从这里我推测最开始抖动的原因是解析图片是异步回调的,但是修改 ScrollView
时是重置了 offset
,在重置刚结束时,获取到新的图片,就发现第一次展示的图片会出现一个占位图。如果同时转化5张图片,并存放到数组,就没有任何问题了。这里只是我根据猜测找到的一种解决方法,甚至猜测的原因都可能是错的,但是确实解决了问题,并且从复用或者逻辑的角度来看,也并没有什么问题,算是作为一个轮播的点写出来共享一下。
完善方法 setPHAssetImages(_: )
:
/// 展示相册图片
fileprivate func setPHAssetImages(isFirst: Bool) {
let index = currentIndex % resource.count
if transformAssetToImageFlags.count == 0 {
// 第一次执行
transformAssetToImageFlags.append(index)
}
// 判断是否已经有相同图片添加到缓存数组
var isAdd = false
transformAssetToImageFlags.forEach { (flag) in
if flag == index {
isAdd = true
}
}
if !isAdd {
transformAssetToImageFlags.append(index)
}
// 左-1
let leftAssetleft = resource[(currentIndex + resource.count - 2) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: leftAssetleft) { (requestImage) in
self.cacheImages[(self.currentIndex + self.resource.count - 2) % self.resource.count] = requestImage
}
// 左
let leftAsset = resource[(currentIndex + resource.count - 1) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: leftAsset) { (requestImage) in
if isFirst {
self.leftImageView.image = requestImage
}
self.cacheImages[(self.currentIndex + self.resource.count - 1) % self.resource.count] = requestImage
}
// 中
let centerAsset = resource[currentIndex % resource.count] as! PHAsset
transformAssetToImage(singleAsset: centerAsset) { (requestImage) in
if isFirst {
self.centerImageView.image = requestImage
}
self.cacheImages[self.currentIndex % self.resource.count] = requestImage
}
// 右
let rightAsset = resource[(currentIndex + 1) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: rightAsset) { (requestImage) in
if isFirst {
self.rightImageView.image = requestImage
}
self.cacheImages[(self.currentIndex + 1) % self.resource.count] = requestImage
}
// 右+1
let rightAssetRight = resource[(currentIndex + 2) % resource.count] as! PHAsset
transformAssetToImage(singleAsset: rightAssetRight) { (requestImage) in
self.cacheImages[(self.currentIndex + 2) % self.resource.count] = requestImage
}
}
运行效果:
其实还是存在一点问题的。上面处理 ScrollView
的偏移值重置操作是在惯性结束后的方法里 scrollViewDidEndDecelerating(_: )
,但是如果滑动很快,即惯性还没结束就连续滑动,就会出来拉不动的情况,因为偏移值没有修改的话,始终只有三个 UIImageView
。如果取消惯性,即令 scrollview.bounces = false
,就会出现偶尔划不动的情况。这里我在 ScrollView
开始滑动时,令 originalOffsetX = scrollView.bounds.width
,虽然缓解了问题,但是还是会出现偶尔快速滑动两次,但是惯性结束方法执行一次的情况,因为 scrollViewWillBeginDragging(_: )
和 scrollViewDidEndDecelerating(_: )
并不是一对一的关系。
上面都是在快速滑动的时候出现的问题,如果开启 timer
实现自动轮播并不会出现上面的问题,所以加个 timer
或者其他轮播方法,可放心使用。
在网上找了一些类似无限轮播的例子,好像并没有注意这方面的问题。还有利用 UICollectionView
来实现轮播功能,不知道会不会出现这个问题,暂时并没有尝试。如果以后有好的思路解决这个问题,再来更新。