《40 Swift Projects》

1、SimpleClock

(1)Timer Class

Timer与Run loop是紧密相关的。Run loop负责维护对其计时器的强引用,因此在将计时器添加到Run loop后,不必维护自己对计时器的强引用。

要有效地使用计时器,应该知道Run循环是如何运行的,参阅线程编程指南

重复计时器和非重复计时器的比较:
  1. 重复计时器会触发,然后在相同的Run loop中重新调度自己。
  2. 非重复计时器触发一次,然后自动使自身无效,从而防止计时器再次触发。
  3. 重复定时器总是根据计划的触发时间而不是实际的触发时间来调度自己。

例如,如果计时器计划在特定时间和之后每5秒触发一次,则计划的触发时间将始终落在原始的5秒时间间隔内,即使实际的触发时间被延迟。如果触发时间延迟到超过一个或多个计划的触发时间,则计时器在该时间段内仅触发一次;然后在触发后将计时器重新调度为未来的下一个计划的触发时间。这里我是这么理解的:比方说,有一个每隔1小时就触发一次的重复计时器,假设30分钟后,用户将APP缩小到桌面,2小时后重新打开APP(假设APP没有被干掉),那么在30分钟后,重复计时器才会第二次触发,中间没有在运行循环中的时间就不算了。

创建计时器有三种方法:
  1. 使用scheduledTimer(timeInterval:invocation:repeats:)scheduledTimer(timeInterval:target:selector:userInfo:repeats:)类方法创建计时器,并将其安排在默认模式下的当前运行循环上
  2. 使用Init(TimeInterval:Invoocation:Repeats:)init(timeInterval:target:selector:userInfo:repeats:)类方法创建Timer对象,而不在Run loop中调度它。(创建计时器后,必须通过调用相应RunLoop对象的Add(_:forMode:)方法手动将计时器添加到Run loop中。)
  3. 分配计时器并使用init(fireAt:interval:target:selector:userInfo:repeats:)方法对其进行初始化。(创建计时器后,必须通过调用相应Run loop对象的Add(_:forMode:)方法手动将计时器添加到Run循环中。)

总的来说,如果使用`scheduledTimer`方法初始化Timer,则自动添加到默认的Run loop中,而使用`init`方法初始化的Timer则需要手动指明要加到哪个Run loop中。

一旦计划运行循环,计时器就会以指定的时间间隔触发,直到其失效。非重复计时器在触发后立即使其自身失效。但是,对于重复计时器,您必须通过调用Timer对象的invalate()方法来使其无效。调用此方法会请求从当前Run loop中移除计时器;因此,应该始终从安装计时器的同一线程中调用invalate()方法。举个栗子🌰:

1
2
3
4
5
6
7
8
9
func beginSC() {
    time = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(changeLabel), userInfo: nil, repeats: true)
}
func stopSC() {
    showLable.text = "0"
    n = 0
    time?.invalidate()
    time = nil
}

2、CustomFont

(1)自定义字体

导入字体步骤:

  1. 下载ttf文件,加入项目中。
  2. 在info.plist中,添加一个字段:Fonts provided by application。
  3. 再添加item,值写入字体的名字。
  4. 然后就可以通过名字使用了。
1
cell.textLabel?.font = UIFont(name: "MFTongXin_Noncommercial-Regular", size: 24)

3、PlayLocalVideo

(1)AVPlayer

概述:

AVPlayer是用于管理媒体资源的回放和计时的控制器对象。您可以使用AVPlayer播放本地和远程基于文件的媒体。

AVPlayer一次只能播放一个视频资源,但可以使用replaceCurrentItem(with:)方法重复使用该播放器实例来播放其他媒体资源。

AVPlayer拥有一个名为AVQueuePlayer的子类,用于创建和管理按顺序播放的媒体资源的队列。(这个还没看,但是估计和AVPlayer差不多,里面应该有一个类型为AVPlayerItem的数组)。

AVPlayer可以播放AVFoundation使用AVAsset类建模的媒体资源。

但AVAsset仅对媒体的静态方面进行建模,如其duringcreatTime,并且其本身不适合使用AVPlayer进行回放。要回放资源,您需要创建在AVPlayerItem中找到的其动态副本的实例。

AVPlayer是一个状态不断变化的动态对象。有两种方法可以监控AVPlayer的状态,分别是一般状态观察和定时状态观测,二者分别使用 Key-value observing (KVO) 和addPeriodicTimeObserver(forInterval:queue:using:) or addBoundaryTimeObserver(forTimes:queue:using:)来实现。

可视化:

AVPlayer和AVPlayerItem是非可视化的对象,也就是说,这些对象无法直接在屏幕上显示,可以通过两种方法将它们显示出来:

  1. AVKit:使用AVKit框架的AVPlayerViewController类。(官方推荐最佳方法)
  2. AVPlayerLayer: 这种方法可以将资源直接加到图层上,因此可以作为背景。
初始化:

AVPlayer有两种初始化方法,分别是基于URL和基于AVPlayerItem:

1
2
3
4
init(url: URL)
// Creates a new player to play a single audiovisual resource referenced by a given URL.
init(playerItem: AVPlayerItem?)
// Creates a new player to play the specified player item.
AVPlayer 的一些属性和方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func play()
// Begins playback of the current item.

func pause()
// Pauses playback of the current item.

var rate: Float
// The current playback rate.

class let rateDidChangeNotification: NSNotification.Name
// A notification that a player posts when its rate changes.

func seek(to: CMTime)
// Sets the current playback time to the specified time.

func seek(to: CMTime, completionHandler: (Bool) -> Void)
// Sets the current playback time to the specified time and executes the specified block when the seek operation completes or is interrupted.

var volume: Float
// The audio playback volume for the player.

var isMuted: Bool
// A Boolean value that indicates whether the audio output of the player is muted.

简单来说,AVPlayer可以用于控制播放🎵各个细节,比如调整播放速率、播放时间,以及暂停⏸️、恢复播放,调整声音🔊、静音🔇等…

和AVPlayerItem之间的关系:

AVPlayerItem存储对AVAsset对象的引用,该对象表示要播放的媒体。

可以通过传递AVPlayerItem来初始化AVPlayer

1
2
3
// AVPlayer.swift
init(playerItem: AVPlayerItem?)
// Creates a new player to play the specified player item.

同样的,可以通过currentItem这个只读属性来查看当前的AVPlayerItem

AVPlayerItem可以通过将所需的keys传递给其init(asset:automaticallyLoadedAssetKeys:)初始值设定项来自动加载所需的数据。这个我还没太看懂是什么意思,给一段官方的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func prepareToPlay() {
    let url = <#Asset URL#>
    // Create asset to be played
    asset = AVAsset(url: url)
    
    let assetKeys = [
        "playable",
        "hasProtectedContent"
    ]
    // Create a new AVPlayerItem with the asset and an
    // array of asset keys to be automatically loaded
    playerItem = AVPlayerItem(asset: asset,
                              automaticallyLoadedAssetKeys: assetKeys)
    
    // Associate the player item with the player
    player = AVPlayer(playerItem: playerItem)
}
AVPlayerItem 的一些属性和方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
init(url: URL)
// Creates a player item with a specified URL.

init(asset: AVAsset)
// Creates a player item for a specified asset.

init(asset: AVAsset, automaticallyLoadedAssetKeys: [String]?)
// Creates a player item with the specified asset and the asset keys to automatically load.

init(asset: AVAsset, automaticallyLoadedAssetKeys: [AVPartialAsyncProperty<AVAsset>])
// Creates a player item for the asset, and automatically loads values for the specified properties.

var status: AVPlayerItem.Status
// The status of the player item.

enum AVPlayerItem.Status
// The statuses for a player item.

var canPlayReverse: Bool
// A Boolean value that indicates whether the item can be played in reverse.

var canPlayFastForward: Bool
// A Boolean value that indicates whether the item can be fast forwared.

var canPlayFastReverse: Bool
// A Boolean value that indicates whether the item can be quickly reversed.

var canPlaySlowForward: Bool
// A Boolean value that indicates whether the item can be played slower than normal.

var canPlaySlowReverse: Bool
// A Boolean value that indicates whether the item can be played slowly backward.

按照目前的理解,AVPlayer是在实际播放的过程中,控制播放速率、暂停播放,等等,而AVPlayerItem则更多的是关注于资源本身的能力,比如,能否支持倍速播放,能否支持快进,等等。

AVPlayerViewController

首先,通过名字就可以看出,它是一个controller(废话),既然是ViewController类型,必然有一个控制的对象。就像ViewController负责控制一个View,AVPlayerViewController控制的是一个player: AVPlayer?。同时,AVPlayerViewController还可以遵循一个AVPlayerViewControllerDelegate类型的协议,这个协议可以用来对视频播放状态的改变做出相应,比如开始画中画,结束画中画, 开始全屏,结束全屏,需要注意的是⚠️,AVPlayerViewControllerDelegate协议中所有的方法都是可选的,也就是说,如果不需要自定义协议中的方法,实际上AVPlayerViewController无须遵循AVPlayerViewControllerDelegate协议。

插播一条小知识:

今天在使用Bundle寻找本地资源时,总是显示添加资源为空:

1
2
let path = Bundle.main.path(forResource: "1", ofType: "mp4")
let playView = AVPlayer(url: URL(fileURLWithPath: path!))

搜了一下发现需要将资源手动先添加到Bundle Resources中,步骤如下:

  • 点击 Project
  • 点击 target
  • 选择 Build Phases
  • 展开 Copy Bundle Resources
  • 点击 ‘+’ 并添加文件
完整代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import UIKit
import AVKit

import AVFoundation // 其实在这里可以不用引入AVFoundation,程序也能正常运行

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

var playViewController: AVPlayerViewController?
var playView: AVPlayer?


override func viewDidLoad() {
    super.viewDidLoad()
    setupView()
}
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    playView = nil
    playViewController = nil
}
  
//隐藏状态栏
override var prefersStatusBarHidden: Bool {
    return true
}

func playVideo() {
    /*
     错误处理:
     1.throwing,把函数抛出的错误传递给调用此函数的代码
     2.do-catch
     3.将错误作为可选类型处理
     4.断言此错误根本不会发生
     */
    let path = Bundle.main.path(forResource: "1", ofType: "mp4")

    if path == nil {
        print("没有该文件!")
        return
    }
    //由于返回了可选类型,并且通过上方判断,可以确定是有值的,然后强制解包

    playView = AVPlayer(url: URL(fileURLWithPath: path!))
    playViewController = AVPlayerViewController()
    playViewController?.player = playView
    playView?.isMuted = true
    playView?.replaceCurrentItem(with: AVPlayerItem(url: URL(fileURLWithPath: path!)))

    self.present(playViewController!, animated: true) {
        self.playView?.play()
    }
}

4、WelcomeView

(1)UIScrollView

ScrollView允许滑动和缩放其包含的视图。UITableViewUITextViewUICollectionView都是它的子类。

关于响应手势:

因为ScrollView没有滚动条,所以它必须知道触摸是否表示要滚动,而不是要跟踪内容中的子视图。为了进行此确定,它通过启动计时器来临时截获触碰事件,并在计时器触发之前查看触摸的手指是否有任何移动。如果计时器触发而位置没有显著变化,则ScrollView将跟踪事件发送到内容视图的被触摸的子视图。如果用户在计时器到期之前将手指拖得足够远,ScrollView将取消子视图中的任何跟踪,并自动执行滚动。子类可以重写TouchesShouldBegin(_:with:in:)isPagingEnabledTouchesShouldCancel(in:)方法(滚动视图调用这些方法),以影响滚动视图处理滚动手势的方式。

ScrollView还处理内容的缩放和平移。当用户做出缩小或缩小手势时,ScrollView会调整内容的偏移量和比例。当手势结束时,管理内容视图的对象根据需要更新内容的子视图。(请注意,手势可能会结束,而手指可能仍然向下。)当手势正在进行时,ScrollView不会向子视图发送任何跟踪调用。

UIScrollView类可以遵循UIScrollViewDelegate协议。要使缩放和平移起作用,代理必须同时实现viewForZoom(in:)scllViewDidEndZooming(_:with:atScale:)。此外,maximumZoomScalemaximumZoomScale必须不同。

一些属性和方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var contentSize: CGSize
// The size of the content view.

var contentOffset: CGPoint
// The point at which the origin of the content view is offset from the origin of the scroll view.

func setContentOffset(CGPoint, animated: Bool)
// Sets the offset from the content view’s origin that corresponds to the receiver’s origin.

var isPagingEnabled: Bool
// A Boolean value that determines whether paging is enabled for the scroll view.

var bounces: Bool
// A Boolean value that controls whether the scroll view bounces past the edge of content and back again.

代码举栗🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let scrollBG = UIScrollView(frame: YHRect)
let images = ["first","second","three"]

func setupView() {
    //遍历数组,同时获得index
    for (index,value) in images.enumerated() {
        let imageView = UIImageView(frame: CGRect(x: YHWidth*CGFloat(index), y: 0, width: YHWidth, height: YHHeight))
        imageView.image = UIImage(named: value)
        //限制边界
        imageView.clipsToBounds = true
        imageView.contentMode = .scaleAspectFill
        scrollBG.addSubview(imageView)
    }
    scrollBG.delegate = self
    scrollBG.isPagingEnabled = true
    scrollBG.contentSize = CGSize(width: YHWidth*CGFloat(images.count), height: YHHeight)

    view.addSubview(scrollBG)
}

(2)UIPageControl

UIPageControl是一个相对简单的控件,主要作用就是显示一系列水平圆点,每个圆点对应于数据模型实体中的一个页面。

当用户点击其中一个圆点时,该控件会通过调用func pageViewController(UIPageViewController, willTransitionTo: [UIViewController])方法向代理发送valueChanged事件。

代码举栗🌰:

1
2
3
4
5
6
7
8
9
let pageControl = UIPageControl(frame: CGRect(x: 0, y: YHHeight-30, width: YHWidth, height: 20))

pageControl.currentPage = 0
pageControl.numberOfPages = 3
pageControl.isEnabled = false
pageControl.pageIndicatorTintColor = .white
pageControl.currentPageIndicatorTintColor = .gray

// 之后只要修改currentPage属性,PageControl就会自动改变

5、PictureBrowse

(1)UICollectionView

应该可以这么理解:UICollectionView的DataSource负责数据,Layout负责布局,Delegate负责事件

UICollectionView管理有序的数据项集合并使用可自定义布局呈现它们的对象。

布局:

UICollectionViewLayout类的对象定义集合视图中内容的视觉排列。Layout对象类似于另一个数据源,不同之处在于它提供可视信息而不是项数据。 通常在创建集合视图时指定布局对象,但也可以通过collectionViewLayout属性动态更改集合视图的布局,设置此属性会立即直接更新布局,而不会以动画方式进行更改。如果要以动画形式显示更改,可以调用setCollectionViewLayout(_:animated:completion:)方法。

Cells and Supplementary Views:

CollectionView会维护一个data source已标记为要重复使用的视图对象的队列或列表。

当需要新的Cell时,不是在代码中显式创建新视图,而是始终将旧视图从复用队列中出列。根据需要使用的view类型的不同,有两种view的请求方法:

1
2
3
4
5
6
7
8
9
dequeeReusableCell(with ReuseIdentifierFor)
                   
dequeueReusableSupplementaryView(ofKind:withReuseIdentifier:for)
                   
// 在调用这两个方法中的任何一个之前,必须告诉CollectionView如何创建相应的视图(如果还不存在)。为此,您必须向集合视图注册一个类:

func register(_ cellClass: AnyClass?, forCellWithReuseIdentifier identifier: String) //注册cell
                   
func register(_ viewClass: AnyClass?, forSupplementaryViewOfKind elementKind: String, withReuseIdentifier identifier: String)// 注册页眉页脚
数据预取:

CollectionView提供了两种预取技术来提高响应速度:

  • Cell预取:Cell的渲染发生在早于该Cell显示所需的时间,从而带来更流畅的滚动体验。Cell Prefetch是默认开启的。

  • Data预取:当Cell的数据加载开销很大的时候,比如数据是通过网络请求得到的时,Data预取显得比较重要,将遵循UICollectionViewDataSourcePrefetching协议的对象分配给prefetchDataSource属性,以接收何时预取单元格数据的通知。(这种方式之前从来没有使用过,可能是因为我们的数据一般都会先请求完成后再刷新UICollectionView

Reordering Items Interactively:

Collection views允许基于用户交互来移动项。可以通过调用beginInteractiveMovementForItem(at:)方法来直接移动,在调用过程中使用updateInteractiveMovementTargetPosition(_:) 方法来记录触摸位置,最后再通过endInteractiveMovement() 来更新试图。

UICollectionView遵循的协议类型为UICollectionViewDelegate,协议中所有方法均为optional,主要包括:管理被选中的Cell、Cell高亮、跟踪添加或删除Cell、响应Layout 的变化、管理上下文菜单等等。

举个栗子🌰:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
//  ViewController.swift
//  PictureBrowse

import UIKit

let YHRect = UIScreen.main.bounds
let YHHeight = YHRect.size.height
let YHWidth = YHRect.size.width

let ItemWidth = YHWidth-40.0
let ItemHeight = YHHeight/3.0

class ViewController: UIViewController {
    
    let backgroundImageView = UIImageView(frame: YHRect)
    var collectionView: UICollectionView!
    let data = CollectionModel.createInterests()
    let reuseIdentifier = "CollectionCell"
    let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    func setupView() {
        backgroundImageView.image = UIImage(named: "blue")
        
        let collectionLayout = UICollectionViewFlowLayout()
        collectionLayout.scrollDirection = .horizontal//滚动方向
        collectionLayout.itemSize = CGSize(width: ItemWidth, height: ItemHeight)//cell大小
        collectionLayout.minimumLineSpacing = 20//上下间隔
        collectionLayout.minimumInteritemSpacing = 20//左右间隔
        collectionLayout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)//section边界
        collectionView = UICollectionView(frame: CGRect(x: 0.0, y: (YHHeight-ItemHeight)/2, width: YHWidth, height: ItemHeight), collectionViewLayout: collectionLayout)
        collectionView.backgroundColor = .clear
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView .register(CollectionCell.self, forCellWithReuseIdentifier: reuseIdentifier)
        
        visualEffectView.frame = YHRect
        
        view.addSubview(backgroundImageView)
        view.addSubview(visualEffectView)
        view.addSubview(collectionView)
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

/*扩展ViewController支持协议*/
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! CollectionCell
        
        cell.data = self.data[indexPath.row] //⚠️,这里为Cell的data属性添加了一个属性观察器,每次更新都会调用SetUpUI方法
        
        return cell
    }
}

(2) UIVisualEffectView

UIVisualEffectView可以为View添加一个毛玻璃的效果!

1
2
3
4
5
6
let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
// 根据style的不同可以选择不同的影响效果

visualEffectView.frame = UIScreen.main.bounds

view.addSubview(visualEffectView)

6、SystemRefreshControl

UIRefreshControl

UIRechresControl对象是附加到任何UIScrollView对象(包括表视图和集合视图)的标准控件。将此控件添加到可滚动的视图中,为用户提供刷新其内容的标准方式。当用户向下拖动可滚动内容区域的顶部时,滚动视图显示刷新控件,开始设置进度指示器动画,并通知您的应用程序。可以使用该通知来更新您的内容并取消刷新控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func configureRefreshControl () {
   // Add the refresh control to your UIScrollView object.
		myScrollingView.refreshControl = UIRefreshControl()
    myScrollingView.refreshControl?.addTarget(self, 
                                             action:#selector(handleRefreshControl),
			                                       for: .valueChanged)
}
    
@objc func handleRefreshControl() {
    // Update your content…

    // Dismiss the refresh control.
    DispatchQueue.main.async {
       self.myScrollingView.refreshControl?.endRefreshing()
    }
}

一些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func beginRefreshing()
// Tells the control that a refresh operation was started programmatically.

func endRefreshing()
// Tells the control that a refresh operation has ended.

var isRefreshing: Bool
// A Boolean value indicating whether a refresh operation has been triggered and is in progress.

var tintColor: UIColor!
// The tint color for the refresh control.

var attributedTitle: NSAttributedString?
// The styled title text to display in the refresh control.

7、GradientColor

这个项目主要是展示渐变的颜色,用到了 CAGradientLayer()这个类。(第一次见到,反正知道他是一个CALayer就好了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let gradientLayer = CAGradientLayer()

func setupGradientLayer() {
    gradientLayer.frame = YHRect
    let color1 = UIColor(white: 0.5, alpha: 0.2).cgColor
    print(color1)
    let color2 = UIColor(red: 1.0, green: 0, blue: 0, alpha: 0.4).cgColor
    let color3 = UIColor(red: 0, green: 1, blue: 0, alpha: 0.3).cgColor
    let color4 = UIColor(red: 0, green: 0, blue: 1, alpha: 0.3).cgColor
    let color5 = UIColor(white: 0.4, alpha: 0.2).cgColor

    gradientLayer.colors = [color1, color2, color3, color4, color5]
    gradientLayer.locations = [0]
    gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
    gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
    view.layer.addSublayer(gradientLayer)
}

// locations 可以控制色彩的分布
// startPoint和endPoint 可以控制渐变的起点和终点 个人感觉也可以理解成渐变的方向
// 这个就当玩儿玩儿吧,其实没有深入理解各个参数的意义,仅凭效果推测的

7、VideoBackground

这个项目主要介绍了怎么能在将视频作为背景播放,实现起来也没有什么特别难的地方,基本和播放本地视频那一节是一样的,只不过他是将VideoPlayerController的View放在了最后一层,这里学一下动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let playerVC = AVPlayerViewController()

func setMoviePlayer() {
    let url = URL(fileURLWithPath: Bundle.main.path(forResource: "moments", ofType: "mp4")!)
    playerVC.player = AVPlayer(url: url)//指定播放源
    playerVC.showsPlaybackControls = false//是否显示工具栏
    playerVC.videoGravity = AVLayerVideoGravity.resizeAspectFill//视频画面适应方式
    playerVC.view.frame = YHRect
    playerVC.view.alpha = 0
    //监听视频播放完的状态
    NotificationCenter.default.addObserver(self, selector: #selector(repeatPlay), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: playerVC.player?.currentItem)
    view.addSubview(playerVC.view)
    view.sendSubviewToBack(playerVC.view)//放到最底层
    UIView.animate(withDuration: 5) {
        self.playerVC.view.alpha = 1;
        self.playerVC.player?.play()
    }
}

8、UIPickerView

UIPickerView相对来说是一个功能比较简单的控件,有点类似于密码箱上的密码盘。和UITableView一样,需要遵循UIPickerViewDelegateUIPickerViewDataSource两个协议。

其中,UIPickerViewDataSource主要负责数据源,有且仅有两个必选的方法:

1
2
3
4
5
6
7
8
// returns the number of 'columns' to display.
@available(iOS 2.0, *)
func numberOfComponents(in pickerView: UIPickerView) -> Int


// returns the # of rows in each component..
@available(iOS 2.0, *)
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int

UIPickerViewDataSource主要负责用户选择之后的回调以及UIPickerView的宽度、高度、View的自定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// returns width of column and height of row for each component. 
optional func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat

optional func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat

// these methods return either a plain NSString, a NSAttributedString, or a view (e.g UILabel) to display the row for the component.
// for the view versions, we cache any hidden and thus unused views and pass them back for reuse. 
// If you return back a different object, the old one will be released. the view will be centered in the row rect  
optional func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String?

optional func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? // attributed title is favored if both methods are implemented

optional func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView // 这个方法感觉挺好玩儿的❤️,可以自定义每一行的view,感觉可以搞出很多有意思的东西,刚才我把所有行都变成了一个小老虎的图标

optional func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int)

9、UIButton图片在上,文字在下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
extension UIButton {
    //设置按钮图片在上,文字在下的效果
    func alignContentVerticallyByCenter() {
        contentHorizontalAlignment = .center
        contentVerticalAlignment = .center
        
        //图片与title之间有一个默认的间隔10
        let offset: CGFloat = 10
        
        //在有的iOS版本上,会出现获得不到frame的情况,加上下面这两句可以100%得到
        titleLabel?.backgroundColor = backgroundColor
        imageView?.backgroundColor = backgroundColor
        
        //title
        let titleWidth = titleLabel?.frame.size.width
        let titleHeight = titleLabel?.frame.size.height
        let titleLef = titleLabel?.frame.origin.x
        let titleRig = frame.size.width-titleLef!-titleWidth!
        
        //image
        let imageWidth = imageView?.frame.size.width
        let imageHeight = imageView?.frame.size.height
        let imageLef = imageView?.frame.origin.x
        let imageRig = frame.size.width-imageLef!-imageWidth!
        
        imageEdgeInsets = UIEdgeInsets(top: 0, left: -imageLef!, bottom: titleHeight!, right: -imageRig)
        titleEdgeInsets = UIEdgeInsets(top: imageHeight!+offset, left: -titleLef!, bottom: 0, right: -titleRig)
    }
}

10、UIEdgeInsets

UIEdgeInsets

先来看看这个UIEdgeInsets:

typedef struct UIEdgeInsets {
    CGFloat top, left, bottom, right;  // specify amount to inset (positive) for each of the edges. values can be negative to 'outset'
} UIEdgeInsets;

原来是结构体,它的四个参数:top, left, bottom, right, 分别表示距离上边界,左边界,下边界,右边界的位移,默认值均为0。

contentEdgeInsets

我们都知道,UIButton按钮可以只设置一个UILabel或者一个UIImageView,还可以同时具有UILabel和UIImageView;如果给按钮设置contentEdgeInsets属性,就是按钮的内容整体(包含UILabel和UIImageView)进行偏移。 按钮内容整体向右下分别移动10像素:

Button.contentEdgeInsets = UIEdgeInsetsMake(10, 10, -10, -10);

titleEdgeInsets & imageEdgeInsets

这两个属性的效果是相辅相成的。如果给一个按钮同事设置了title和image,他们默认的状态是图片在左,标题在右,而且image和title之间没有空隙;那就这就引出一个问题,title和image的UIEdgeInsets属性分别的相对于谁而言的?

真相只有一个: image的UIEdgeInsets属性的top,left,bottom都是相对于按钮的,right是相对于title; title的UIEdgeInsets属性的top,bottom,right都是相对于按钮的,left是相对于image;

知道真相的你不知道有没有眼泪流下来,怪不得之前怎么设置都不是想要的结果,原来相对于谁的位移压根没有搞清楚。现在既然搞清楚了,我们来试一下:

title在左,image在右:

1
2
3
4
5
6
7
8
9
10
//拿到title和image的大小:
let titleWidth = titleLabel?.frame.size.width
let titleHeight = titleLabel?.frame.size.height

//image
let imageWidth = imageView?.frame.size.width
let imageHeight = imageView?.frame.size.height
//分别设置偏移量:记住偏移量是位移
imageEdgeInsets = UIEdgeInsets(top: 0, left: titleWidth!, bottom: 0, right: -titleWidth!)
titleEdgeInsets = UIEdgeInsets(top: 0, left: -imageWidth!, bottom: 0, right: imageWidth!)

image在上,title在下:

1
2
3
4
5
//图片 向右移动的距离是标题宽度的一半,向上移动的距离是图片高度的一半
//标题 向左移动的距离是图片宽度的一半,向下移动的距离是标题高度的一半

imageEdgeInsets = UIEdgeInsets(top: -imageHeight!/2, left: titleWidth! / 2, bottom: imageHeight!/2, right: -titleWidth! / 2)
titleEdgeInsets = UIEdgeInsets(top: titleHeight!/2, left: -imageWidth!/2, bottom: -titleHeight!/2, right: imageWidth!/2)

按照我现在的理解,只要能够保证UIEdgeInsets中的「上和下」、「左和右」是互为相反数就可以了,其他的不用考虑那么多

比方说, UIEdgeInsetsMake(10, 0, -10, 0)就代表按钮中的内容向下整体平移10像素

先这么理解,有问题再来填坑。

11、NotificationCenter

基本用法:

1
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)

observer:注册观察器的对象👀

selector:发送通知时调用的方法,该方法必须有且只能有一个参数(NSNotification的实例)。

name:通知的名称,这里感觉比较混乱,因为这个参数的类型是NSNotification.Name?,但是实际使用的时候,这个结构体中的静态属性并不能包含所有通知的名称,比如说上面这个代码的name参数就是从UIResponder的extension中取到的,现在还不是很理解。

填坑了填坑了!

在SWIFT 4.2中,大量的Notification.Name实例成为其他类中的实例变量。例如,keyboardWillShowNotification现在是UIResponder的实例变量。

刚才又看到了Name结构体的定义:

1
2
3
4
5
6
extension NSNotification {
    public struct Name : Hashable, Equatable, RawRepresentable, @unchecked Sendable {
        public init(_ rawValue: String)
        public init(rawValue: String)
    }
}

也就是说,NSNotification.Name是可以自己定义的,可是如果自己定义一种通知名称的话,如何指定什么时候发送这个通知呢?晕了。

关于键盘出现、消失的通知名可以参考这里。原来是Xcode的一个Bug啊!还好我机智,没看答案自己都能改过来,哈哈哈!

技术感觉上去了,加油!

12、键盘拉起,消失的一些注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@objc func keyboardWillShow(note: Notification) {
        let keyboardHeight = (note.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue.height
  // 上面这句获取键盘高度是真没看懂。
  
        Log("键盘高度:\(keyboardHeight)")
        if keyboardHeight/2 == -view.frame.origin.y {
            Log("无需再次移动!")
            return
        }
        UIView.animate(withDuration: 1) { 
            self.view.frame = CGRect(x: 0, y: -keyboardHeight/2, width: YHWidth, height:YHHeight)
        }
    }
    
    @objc func keyboardWillHiden( note: Notification) {
        if view.frame.origin.x == 0 {
            Log("无需再次复位!")
        }
        UIView.animate(withDuration: 1) { 
            self.view.frame = YHRect
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        view.endEditing(true)
      // 点击任何地方都能结束编辑。
    }

13、给Cell中的按钮添加点击事件🔘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cellId") as! InvestDetailCell
    // 这个操作真的学到了 卧槽。
    // 原来给cell添加绑定事件还能用这种方式??
    // 通过这种方式给cell中的每个btn添加了点击事件
    // 不会有手势冲突嘛?

    cell.investAction = {
        () -> Void in
        self.investBtnAction()
    }
    cell.createCellWith(Section: indexPath.section, Row: indexPath.row, Progress: scale, IsCompleted: isCompleted)
    if indexPath.section == 1 {
        cell.createFooterViewWith(IsCompleted: isCompleted)
    }
    cell.configCellWithModel(Model: investDetailModel)
    cell.selectionStyle  = .none
    return cell
}

14、ScrollView底部判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func scrollViewDidScroll(_ scrollView: UIScrollView) {

    // 怎么判断是否划到底部?
    // 假设scrollView.frame.size.height == scrollView.contentSize.height
    // 那么在这种情况下,是不是偏移量scrollView.contentOffset.y只要 >= 0 就说明划到底部了?
    // 所以 只要scrollView.frame.size.height + scrollView.contentOffset.y >= scrollView.contentSize.height
    // 就能说明划到底部。
    if scrollView.contentSize.height - scrollView.contentOffset.y <= scrollView.frame.size.height {
        if isAlreadySwipeInBottom {
            isSecondAlreadySwipeInBottom = true
        }

        isAlreadySwipeInBottom = true
    }
}

15、悬浮窗

touchesBegantouchEnded等方法是UIResponder的类方法。该类还有becomeFirstResponder() -> BoolresignFirstResponder() -> Bool等方法。

UIView拥有hitTestpoint方法,用于确定第一响应对象,hitTestpoint的关系是:hitTest会递归的调用point,直到确定最合适的响应对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import UIKit

class myView: UIView {
    
    lazy var btn = makeButton()
    lazy var btn2 = makeButton2()
    var canDrag = false
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.addSubview(btn)
        self.addSubview(btn2)
        self.backgroundColor = .red
        btn.isUserInteractionEnabled = false
        self.bringSubviewToFront(btn)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func makeButton() -> UIButton {
        let btn = UIButton()
        btn.frame = CGRect(x: 0, y: 100, width: 50, height: 20)
        btn.backgroundColor = .yellow
        return btn
    }
    
    func makeButton2() -> UIButton {
        let btn = UIButton()
        btn.frame = CGRect(x: 0, y: 200, width: 50, height: 20)
        btn.addTarget(self, action: #selector(btnClicked), for: .touchUpInside)
        btn.backgroundColor = .green
        return btn
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first!
        if btn.frame.contains(touch.location(in: self)) {
            canDrag = true
        }
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch = touches.first!
        if canDrag == true {
            btn.center = touch.location(in: self)
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        UIButton.animate(withDuration: 0.2, animations: {
            let x = self.btn.frame.minX
            let y = self.btn.frame.minY
            let height = self.btn.frame.height
            let width = self.btn.frame.width
            if x > UIScreen.main.bounds.midX {
                self.btn.frame = CGRect(x: UIScreen.main.bounds.width - width, y: y, width: width, height: height)
            } else {
                self.btn.frame = CGRect(x: 0, y: y, width: width, height: height)
            }

        }, completion: nil)
        canDrag = false
    }
    
    @objc func btnClicked() {
        // 由于重写了hitTest方法, 所以当触摸位置为btn和btn2的重合部分时,btn2的target方法不会调用。
        print("button clicked") 
    }
    
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if btn.frame.contains(point) {
            return btn
        } else {
            return super.hitTest(point, with: nil)
        }
    }
    
//    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
//
//    }
}

16、可左滑编辑的UITableView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return true
}
//实现该方法,并返回数据,就会开启cell的左划功能
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
    let delete = UITableViewRowAction(style: .normal, title: "删除") { (action, index) in
        print("删除")
        let alert = UIAlertController(title: "是否删除?", message: "删除了就没有了哟!", preferredStyle: .alert)
        let cancel = UIAlertAction(title: "取消", style: .cancel, handler: nil)
        let delete = UIAlertAction(title: "确定", style: .destructive, handler: { (action) in
            self.datas.remove(at: indexPath.row)
            tableView.deleteRows(at: [indexPath], with: .fade)
        })
        alert.addAction(cancel)
        alert.addAction(delete)
        self.present(alert, animated: true, completion: nil)
    }
    delete.backgroundColor = .red

    let edit = UITableViewRowAction(style: .normal, title: "编辑") { (action, index) in
        let alert = UIAlertController(title: "修改名称", message: "随便改!", preferredStyle: .alert)
        let cancel = UIAlertAction(title: "取消", style: .cancel, handler: nil)
        let delete = UIAlertAction(title: "确定", style: .destructive, handler: { (action) in
            self.datas[indexPath.row].title = (alert.textFields?.first?.text)!
            tableView.reloadRows(at: [indexPath], with: .fade)
        })
        alert.addTextField(configurationHandler: { (textField) in
            textField.placeholder = "输入新的名称!"
            textField.text = self.datas[indexPath.row].title
            textField.clearButtonMode = .whileEditing
        })
        alert.addAction(cancel)
        alert.addAction(delete)
        self.present(alert, animated: true, completion: nil)
    }
    edit.backgroundColor = .blue

    let share = UITableViewRowAction(style: .normal, title: "分享") { (action, index) in
        print("分享")
        let firstItem = self.datas[indexPath.row]
        let activityVC = UIActivityViewController(activityItems: [firstItem.title], applicationActivities: nil)
        self.present(activityVC, animated: true, completion: { 
            tableView.reloadRows(at: [indexPath], with: .fade)
        })
    }
    share.backgroundColor = .orange

    return [delete,share,edit]
}

17、UIGestureRecognizer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let pan = UILongPressGestureRecognizer(target: self, action: #selector(panHandler(sender:)))
someView.addGestureRecognizer(pan)

//手势触发,这里主要学习使用state来判断当前触摸的状态,有点类似于touchBegan,touchMoved,touchEnded方法
@objc func panHandler(sender: UILongPressGestureRecognizer) {

    //手势点在collectionView的位置
    let collectionViewPoint = sender.location(in: self)
    //手势点在父view的位置
    let viewPoint = sender.location(in: superview)

    print(collectionViewPoint, viewPoint) 
    //通过print发现,collectionViewPoint的y是相对于滚动条顶部的距离,该距离可以大于屏幕的高度(因为可以一直向下滚动)
    //而viewPoint每次都是相对于屏幕左上角原点的距离
  
    //手势按下
    if sender.state == .began {
        //获得手势点的cell
      
        //这里学到了一个之前没有见过的方法,indexPathForItem方法可以将一个Point转换成indexPath
        if let index = indexPathForItem(at: collectionViewPoint), let originCell = cellForItem(at: index) {
            beginMoveItemAtIndex(index: index, cell: originCell ,viewCenter: viewPoint)
        }
    }
    //手势改变
    else if sender.state == .changed {
        updateMoveItem(viewPoint: viewPoint, collectionViewPoint: collectionViewPoint)
    }
    //手势抬起
    else if sender.state == .ended {
        endMoveItem()
    }
}