肇鑫的技术博客

业精于勤,荒于嬉

自定义UITableViewCell的选中背景

默认情况下,UITableViewCell的选中效果如图。单独选中的效果给人的感觉不是那么可靠。

但从编程角度看,使用选中状态比较简单。不使用选中,而采用标记替代选中比较麻烦。因为这个对号是属于UITableViewCellaccessoryType属性。它并不是和选中状态同步的。需要单独、小心地处理,稍有不慎,就会出现各种错误。

解决思路

创建一个背景透明的对号标记

我最初的想法是创建一个背景透明的对号标记。然后把它应用在UITableViewCellselectedBackgroundView属性上。

这条路不好走。因为一旦制定了selectedBackgroundView。表格之间的分隔线就没有。解决方案有利用各种方式画分隔线的。比较麻烦。

在我采用默认的表格,并允许多选时,我发现苹果的默认选中,就是没有分割线的,只不过它用了有颜色的背景。这在单选时不明显,一旦有多选,就很明显。

创建一个有背景色的对号标记

创建好的图片如图。

checkmark_background@2x

用1x的语言描述,背景:宽320,高44。对号:宽16,高32,水平居中,右边距16。

难点来了

我们知道,不同格式设备的宽度是不一样的。那么对于320这个iOS设备的最小宽度,这个图片在遇到更宽的设备时是需要拉伸的。

关于如何拉伸图片,我们可以看这篇:UIImage图片拉伸平铺(resizableImage)

看着不难,实际上全是坑。因为思维习惯不是直接的,是间接的。

简述一下拉伸图片的原理

如图,如果有一个图片,被分成1-9共9个部分。那么它的四个角的1、3、7、9图形保持不变。我们通过定义2/上、4/左、8/下、6/右的方式,来确定5的范围,然后选择拉伸5。这个就是定义图片拉伸的原理。

1 2 3
4 5 6
7 8 9

下面应用这个原理,放大上面的带对号的背景图。

checkmark_background 1-9@2x

因为我们需要保证对号的位置不变,所以要把它画在3的位置。这是唯一的限制。根据这个限制我们可以计算出。

  • 上:
    • 上边距 = (背景高 - 对号高) / 2 = (44 - 32) / 2 = 6
    • 上 = 上边距 + 对号高 = 6 + 32 = 38
  • 右 = 对号宽 + 右边距 = 16 + 16 = 32

至于下和左,我们因为要尽量保证5越大越好,就都取1。

最终代码如下:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "sampleCell", for: indexPath)
    
    // 获得背景图
    let image = #imageLiteral(resourceName: "Checkmark_background")
    // 指定拉伸范围
    let edgeInsets = UIEdgeInsetsMake(38, 1, 1, 32)
    // 获得拉伸图片
    let resizableImage = image.resizableImage(withCapInsets: edgeInsets, resizingMode: .stretch)
    // 生成图片视图,大小与表格项相同
    let imageView = UIImageView(frame: cell.bounds)
    // 指定图片
    imageView.image = resizableImage
    // 指定表格项选中时的背景视图
    cell.selectedBackgroundView = imageView
    // 设定文字
    cell.textLabel?.text = "条件\(indexPath.row)"

    return cell
}

最终效果

如图:

fina

UIImageView异步加载导致问题的解决

UIImageView加载了一张图片之后,如果再加载另外一张图片,然后立即删除第二张图片。则UIImageView会变黑。

演示代码如下:

import UIKit

class ViewController: UIViewController {
    lazy var image = { () -> UIImage? in
        guard let path = Bundle.main.path(forResource: "poster_icon_mac_1024", ofType: "png") else {
            return nil
        }
        
        return UIImage(contentsOfFile: path)
    }()
    
    lazy var replaceImage = { () -> UIImage? in
        guard let path = Bundle.main.path(forResource: "Miss Devil 恶魔人事·椿真子.Miss.Devil.Jinji.no.Akuma.Tsubaki.Mako.Ep05.Chi_Jap.HDTVrip.1280X720-0001", ofType: "png") else {
            
            return nil
        }
        
        return UIImage(contentsOfFile: path)
    }()
    
    lazy var saveImageURL = { () -> URL in
        let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
        let fileName = UUID().uuidString + ".jpg"
        
        return URL(fileURLWithPath: fileName, isDirectory: false, relativeTo: baseURL)
    }()
    
    var latestImage:UIImage? = nil

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        
        addImage()
        
        DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
            self.createImage()
            self.loadImage()
            self.changeImageViewToLatestImage()
            self.removeLatestImage()
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @IBOutlet weak var imageView: UIImageView!
    
    func addImage() {
        imageView.image = image
    }
    
    func changeImage() {
        imageView.image = replaceImage
    }
    
    func createImage() {
        let imageData = UIImageJPEGRepresentation(replaceImage!, 1.0)
        FileManager.default.createFile(atPath: saveImageURL.path, contents: imageData!, attributes: nil)
    }
    
    func loadImage() {
        latestImage = UIImage(contentsOfFile: saveImageURL.path)
    }
    
    func changeImageViewToLatestImage() {
        imageView.image = latestImage
    }
    
    func removeLatestImage() {
        try! FileManager.default.removeItem(at: saveImageURL)
    }
}

分析与解决

这个问题的造成,是因为为了性能,UIImageView其实是异步加载的。在它加载完成之前,如果图片被删除了,就会加载不到,变黑。

有人提出,可以将图片通过Data类型,加载到内存中,然后将内存中的图片加载到UIImageView的方式,来绕过这个问题。这个思路的确能解决这个问题,但是毕竟多占用了内存。不算是最好的方式。

其实,我们知道了原理,就等UIImageView加载完成之后,再删除图片就好了。虽然苹果并没有告诉我们图片何时加载好。但是我们知道,类似更新UI的这种操作,必然是在UI线程完成的,即main线程。那么我们只需要在main队列排队一下就可以了。

将代码

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage()
    self.removeLatestImage()
}

改成

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage()

    DispatchQueue.main.async { [unowned self] in
        self.removeLatestImage()
    }
}

思考

为什么上面的代码会成功呢?这是因为第一段代码的运行顺序是

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage() // 异步操作,押后运行
    self.removeLatestImage() // 先删除了图片,之后才进行的图片加载
}

而修改之后的代码是

DispatchQueue.main.asyncAfter(wallDeadline: .now() + .seconds(2)) { [unowned self] in
    self.createImage()
    self.loadImage()
    self.changeImageViewToLatestImage() // 异步操作1,押后运行

    DispatchQueue.main.async { [unowned self] in // // 异步操作2,押后运行
        self.removeLatestImage()
    }
}

由于main队列是一个包执行完成之后,才会执行下一个。因此实际上修改后的代码是在图片加载完成之后才删除图片的。这就避免了这个问题。

循环结构中异步代码实现灵活退出(下)

循环结构中异步代码实现灵活退出(上)

上一篇中,我们实现了基本结构。但是如果每次都这么做,会比较麻烦。这一篇中,我们尝试将代码封装起来,这样以后我们再做时,只需调用一次就可以了。

首先,我们将之前的代码变成closure。

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        concurrentQueue.async {
            self.run()
        }
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i, withClosure: { (result) in
                DispatchQueue.main.async {
                    self.textView.string += "\(i)\n"
                }
                
                if i == 5 {
                    result = true
                }
            }) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int, withClosure closure: @escaping (inout Bool) -> ()) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)
        
        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            closure(&result)
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }

    @IBOutlet var textView: NSTextView!
}

接下来,我们新建一个文件RangeEnumerator.swift,扩展Sequence

import Foundation

extension Sequence {
    public func breakableForEach(closureWithCondition: @escaping (Element, inout Bool)->()) {
        let semaphore = DispatchSemaphore(value: 0)
        
        DispatchQueue.global().async {
            for e in self {
                if self.shouldBreak(semaphore, e, withClosure: closureWithCondition) {
                    break
                }
            }
        }
    }
    
    private func shouldBreak(_ semaphore:DispatchSemaphore, _ e:Element, withClosure closure: @escaping (Element, inout Bool) -> ()) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)
        
        DispatchQueue.global().asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            closure(e, &result)
            semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
}

之后,最初的代码就可以简化为。

class ViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        (1...10).breakableForEach { (i, result) in
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }

            if i == 5 {
                result = true
            }
        }
    }
    
    @IBOutlet var textView: NSTextView!
}

另外,由于扩展中不能创建存储变量,所以,之前的信号量和队列,就必须放在函数里了。