前言
我們常見的一些廣告位、圖片輪播都是可以無限輪播的,以前參考文章 iOS開發(fā)系列--無限循環(huán)的圖片瀏覽器,自己也在項(xiàng)目中實(shí)際應(yīng)用了,現(xiàn)在用Swift重新實(shí)現(xiàn)一遍,發(fā)現(xiàn)代碼原來可以更加精簡,并且輪播手機(jī)相冊(cè)的所有圖片時(shí)遇到了一些問題,所以記錄一下,幫助需要用到的同學(xué)快速實(shí)現(xiàn)這個(gè)小功能。
功能實(shí)現(xiàn)
- 先看一下效果圖,這里分為本地圖片、網(wǎng)絡(luò)圖片、相冊(cè)三個(gè)部分來實(shí)現(xiàn),首先看一下本地效果。
其實(shí)本地的實(shí)現(xiàn)是最簡單的,相冊(cè)的實(shí)現(xiàn)更加復(fù)雜一些,因?yàn)橄鄡?cè)圖片牽扯到 PHCachingImageManager
的異步回調(diào)獲取圖片,需要單獨(dú)去處理。網(wǎng)絡(luò)圖片如果使用 Kingfisher
來處理,那么就和本地圖片一樣了,但是如果使用系統(tǒng)的方法,即使用
let url = URL(string: urlString)
let imageData = try! Data(contentsOf: url)
let image = UIImage(data: imageData)
來獲取圖片,會(huì)有一些問題。下面會(huì)有單獨(dú)說明。
這里使用的 Xcode8
、 Swift3
。
原理分析
這里一共使用了3個(gè)UIImageView
,然后滑動(dòng)結(jié)束時(shí)重置ScrollView
的偏移值。-
代碼實(shí)現(xiàn)-首頁
首先是項(xiàng)目簡單地文件目錄。
簡單文件結(jié)構(gòu)
然后是Main.storyboard
文件。這里能用故事版畫的控件我都是拒絕手寫的。
Storyboard
這里有一點(diǎn)需要注意,獲取相冊(cè)信息時(shí),需要在info.plist
文件中添加字段,并且是必須添加,否則直接報(bào)錯(cuò)。
訪問系統(tǒng)相冊(cè)
首頁的代碼并沒有多少要說明的,就是一些基本的準(zhǔn)備工作,全部代碼如下(部分說明都在注釋中:
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
}
// 網(wǎng)絡(luò)圖片鏈接
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"]
// 相冊(cè)圖片
fileprivate var allAssets = PHAsset
override func viewDidLoad() {
super.viewDidLoad()
getAlbumAssets { (assets) in
self.allAssets = assets
}
}
/// 獲取系統(tǒng)相冊(cè)數(shù)據(jù)
///
/// - parameter callback: 回調(diào)結(jié)果
fileprivate func getAlbumAssets(callback: @escaping ([PHAsset]) -> Void) {
PHPhotoLibrary.requestAuthorization { (status) in
guard status == PHAuthorizationStatus.authorized else {
print("獲取相冊(cè)權(quán)限后再進(jìn)行操作")
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
}
}
}
#####輪播主體代碼 - 本地圖片
- 我把主要需要注意的點(diǎn)和注釋都寫在代碼里了,參考時(shí)只需要注意一些 `storyboard` 的連接控件即可。因?yàn)槲以贒emo中需要處理 `UIImage` 、`String` 、 `PHAsset` 三種類型,所以在接收方法里用了 `[Any]` ,導(dǎo)致處理的時(shí)候要去分別判斷。只是單個(gè)數(shù)據(jù)類型的話并不需要這么麻煩。
下面這些是實(shí)現(xiàn)的基本代碼,后面關(guān)于網(wǎng)絡(luò)圖片和相冊(cè)圖片都是在這個(gè)頁面里添加對(duì)應(yīng)的方法。
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
}
/// 統(tǒng)一設(shè)置展示控件
///
/// - parameter imageViewPosition: 左、中、右位置
///
/// - returns: 設(shè)置完成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
}
/// 展示網(wǎng)絡(luò)圖片
fileprivate func setNetImages() {
}
/// 展示相冊(cè)圖片
fileprivate func setPHAssetImages() {
}
// MARK: - UIScrollView Delegate
/// 開始拖拽ScrollView時(shí)
fileprivate var originalOffsetX: CGFloat = 0
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
originalOffsetX = scrollView.contentOffset.x
}
/// ScrollView 慣性結(jié)束
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 當(dāng)滑動(dòng)幅度不夠時(shí),保留ScrollView的原生特性
if originalOffsetX == scrollView.contentOffset.x {
return
}
if currentIndex == 0 {
currentIndex = resource.count
}
// 滑動(dòng)時(shí),更改顯示
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)
}
}
核心代碼到這里就結(jié)束了。上面的代碼直接復(fù)制到自己新建的demo 中就是可以直接運(yùn)行的。下面則是對(duì)于網(wǎng)絡(luò)圖片和相冊(cè)圖片的擴(kuò)展,因?yàn)槎紶砍兜疆惒教幚恚远际菍?duì)上面代碼的補(bǔ)充。
##### 網(wǎng)絡(luò)圖片的輪播
1. 首先說不使用第三方框架,僅僅使用下面主要代碼獲取網(wǎng)絡(luò)圖片時(shí),
let url = URL(string: urlString)
let imageData = try! Data(contentsOf: url)
let image = UIImage(data: imageData)
如果放在主線程去做,會(huì)造成很嚴(yán)重的卡頓,如果開啟線程,然后單獨(dú)下載圖片,會(huì)因?yàn)楫惒降膯栴}導(dǎo)致保存圖片的數(shù)組內(nèi)部重復(fù)保存。如果開啟線程依賴,也會(huì)有問題。總之,嘗試失敗,好在不是輪播重點(diǎn),暫時(shí)pass。有好的思路的時(shí)候再來補(bǔ)充。
2. 利用 `cocoapods` 導(dǎo)入第三方圖片框架 `Kingfisher` ,至于 `cocoapods` 的使用,網(wǎng)上有很多非常完善的教程。那么問題處理就簡單很多了,完全像本地圖片一樣了。
效果圖:

首先,定義默認(rèn)圖片屬性
fileprivate var placeHodlerImage = UIImage(named: "defaultLoad.png")
然后,完善方法 `setNetImages()` :
/// 展示網(wǎng)絡(luò)圖片
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)
}
##### 本地相冊(cè)圖片的輪播
1. 帶有瑕疵的方案,之所以要說這一點(diǎn),因?yàn)橐郧拔揖鸵恢笔褂玫倪@種方式,遇到了一些坑,直到最后解決。
**首先**,添加相冊(cè)元數(shù)據(jù) `PHAsset` 轉(zhuǎn)化為 `UIImage` 的方法:
/// 將Asset轉(zhuǎn)化為UIImage
///
/// - parameter singleAsset: 元數(shù)據(jù)
///
/// - 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()` :
/// 展示相冊(cè)圖片
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
}
}
}
最后,看效果:

可以很明顯的看到圖片的抖動(dòng)現(xiàn)象,這個(gè)問題其實(shí)現(xiàn)在我也不確定是什么原因造成的,只是結(jié)合自己后來的嘗試找到了解決方法,算是以結(jié)果倒推原因。
2. 改善版。為了避免重復(fù)的使用方法 `PHCachingImageManager` 來解析圖片,建立一個(gè)臨時(shí)圖片數(shù)組,存放解析到的圖片,然后從從圖片數(shù)組中讀取圖片。
**首先**,創(chuàng)建一個(gè)臨時(shí)圖片數(shù)組:
/// 將相冊(cè)圖片緩存到內(nèi)存中
fileprivate var cacheImages: [UIImage]!
**然后**,在接收方法中初始化:
// MARK: - 開放接口
internal func getDate(resourceArray: [Any], _ type: DataType) {
dataType = type
resource = resourceArray
cacheImages = Array(repeating:placeHodlerImage!, count: resource.count)
}
**再然后**,完善方法 `setPHAssetImages()` 。定義一個(gè) 標(biāo)識(shí)數(shù)組,確保相冊(cè)的每一項(xiàng) `PHAsset` 都已經(jīng)被轉(zhuǎn)化為 `UIImage` 。只有第一次解析圖片時(shí),才會(huì)直接將圖片放置到左中右三個(gè) `UIImageView` 上,然后開始滑動(dòng) `ScrollView` 時(shí),再次解析圖片, `ScrollView` 慣性結(jié)束時(shí), 則是從緩存中讀取。
設(shè)置標(biāo)識(shí)數(shù)組:
/// asset 是否完全轉(zhuǎn)化為 Image 的標(biāo)識(shí)
fileprivate var transformAssetToImageFlags = [Int]()
完善 `setPHAssetImages()` 方法。這里添加了一個(gè) `isFirst` 布爾參數(shù),為了判斷是否是第一次進(jìn)來解析圖片。
/// 展示相冊(cè)圖片
fileprivate func setPHAssetImages(isFirst: Bool) {
let index = currentIndex % resource.count
if transformAssetToImageFlags.count == 0 {
// 第一次執(zhí)行
transformAssetToImageFlags.append(index)
}
// 判斷是否已經(jīng)有相同圖片添加到緩存數(shù)組
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` 拖動(dòng)時(shí)的方法:
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
originalOffsetX = scrollView.contentOffset.x
switch dataType! {
case .localImage:
break
case .netImageUrl:
break
case .asset:
// 判斷是否所有圖片都已經(jīng)轉(zhuǎn)化
if transformAssetToImageFlags.count == resource.count {
break
}
setPHAssetImages(isFirst: false)
}
}
添加讀取圖片的方法:
/// 從緩存的圖片數(shù)組中讀取數(shù)據(jù)
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` 慣性結(jié)束的方法:
/// ScrollView 慣性結(jié)束
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 當(dāng)滑動(dòng)幅度不夠時(shí),保留ScrollView的原生特性
if originalOffsetX == scrollView.contentOffset.x {
return
}
if currentIndex == 0 {
currentIndex = resource.count
}
// 滑動(dòng)時(shí),更改顯示
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()
}
}
展示效果:

其實(shí)也就是從這里我推測最開始抖動(dòng)的原因是解析圖片是異步回調(diào)的,但是修改 `ScrollView` 時(shí)是重置了 `offset` ,在重置剛結(jié)束時(shí),獲取到新的圖片,就發(fā)現(xiàn)第一次展示的圖片會(huì)出現(xiàn)一個(gè)占位圖。如果同時(shí)轉(zhuǎn)化5張圖片,并存放到數(shù)組,就沒有任何問題了。這里只是我根據(jù)猜測找到的一種解決方法,甚至猜測的原因都可能是錯(cuò)的,但是確實(shí)解決了問題,并且從復(fù)用或者邏輯的角度來看,也并沒有什么問題,算是作為一個(gè)輪播的點(diǎn)寫出來共享一下。
完善方法 `setPHAssetImages(_: )` :
/// 展示相冊(cè)圖片
fileprivate func setPHAssetImages(isFirst: Bool) {
let index = currentIndex % resource.count
if transformAssetToImageFlags.count == 0 {
// 第一次執(zhí)行
transformAssetToImageFlags.append(index)
}
// 判斷是否已經(jīng)有相同圖片添加到緩存數(shù)組
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
}
}
運(yùn)行效果:

##### 再說一點(diǎn)
其實(shí)還是存在一點(diǎn)問題的。上面處理 `ScrollView` 的偏移值重置操作是在慣性結(jié)束后的方法里 `scrollViewDidEndDecelerating(_: )`,但是如果滑動(dòng)很快,即慣性還沒結(jié)束就連續(xù)滑動(dòng),就會(huì)出來拉不動(dòng)的情況,因?yàn)槠浦禌]有修改的話,始終只有三個(gè) `UIImageView`。如果取消慣性,即令 `scrollview.bounces = false` ,就會(huì)出現(xiàn)偶爾劃不動(dòng)的情況。這里我在 `ScrollView` 開始滑動(dòng)時(shí),令 `originalOffsetX = scrollView.bounds.width` ,雖然緩解了問題,但是還是會(huì)出現(xiàn)偶爾快速滑動(dòng)兩次,但是慣性結(jié)束方法執(zhí)行一次的情況,因?yàn)?`scrollViewWillBeginDragging(_: )` 和 `scrollViewDidEndDecelerating(_: )` 并不是一對(duì)一的關(guān)系。
上面都是在快速滑動(dòng)的時(shí)候出現(xiàn)的問題,如果開啟 `timer` 實(shí)現(xiàn)自動(dòng)輪播并不會(huì)出現(xiàn)上面的問題。
在網(wǎng)上找了一些類似無限輪播的例子,好像并沒有注意這方面的問題。還有利用 `UICollectionView` 來實(shí)現(xiàn)輪播功能,不知道會(huì)不會(huì)出現(xiàn)這個(gè)問題,暫時(shí)并沒有嘗試。如果以后有好的思路解決這個(gè)問題,再來更新。