一頁程式,一頁城市

Wen Lo
彼得潘的 Swift iOS / Flutter App 開發教室
17 min readDec 17, 2021

在viewDidLoad中製作一頁App

這次練習是結合近兩個月以來學到的技巧來做出一頁App。絞盡腦汁後決定以城市旅遊為主題,運用到的技巧如下:

  1. 以CAGradientLayer製作漸層背景
  2. 動畫繪製CAShapeLayer的path
  3. 以SpriteKit製作粒子發射特效
  4. 以AVFoundation SDK播放音樂
  5. 以自訂類別製作不規則傾斜底邊的ImageView
  6. 加入gif動畫

漸層背景

在背景的部分希望可以營造夜晚的都會感,所以選擇了以下顏色組合:

然後用startPoint和endPoint調整了一下漸層的方向,讓顏色變化的方向帶點角度,另外再套用locations,考量讓後面的粒子效果明顯一點,所以讓深色的區塊大一些。最深的黑色就佔了60%(0.4~1)的比例。如果問我整個view上半部都是圖片,也看不太到漸層效果那又何必採用漸層效果呢?答案很簡單,因為這是練習。

func setupGradienBackground() {
let gradientLayer = CAGradientLayer()
//將漸層layer的frame和view的邊界對齊
gradientLayer.frame = view.bounds
//設定漸層的顏色組合
gradientLayer.colors = [
CGColor(red: 97/255, green: 0/255, blue: 148/255, alpha: 1),
CGColor(red: 63/255, green: 0/255, blue: 113/255, alpha: 1),
CGColor(red: 21/255, green: 0/255, blue: 80/255, alpha: 1),
UIColor.black.cgColor
]
//設定漸層顏色變化的方向
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.7, y: 1)
//設定漸層各個顏色的佔比
gradientLayer.locations = [0, 0.2, 0.4, 1]

//插入漸層layer使用insert而不是addLayer才不會蓋到storyboard的元件
view.layer.insertSublayer(gradientLayer, at: 0)
}

然後將上面的函式加到viewDidLoad()裡執行。

超美的吧

會自己畫的建築物輪廓

這個部分就是用到之前學到可以畫出任意圖案的UIBazierPath和CAShapeLayer技巧。也是先在viewDidLoad上方將函式寫好:

func addCity() {
//開始畫建築物輪廓,線條轉折很多請見諒
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: 800))
path.addLine(to: CGPoint(x: 33, y: 800))
path.addLine(to: CGPoint(x: 33, y: 780))
path.addLine(to: CGPoint(x: 43, y: 780))
path.addLine(to: CGPoint(x: 43, y: 755))
path.addLine(to: CGPoint(x: 53, y: 745))
path.addLine(to: CGPoint(x: 53, y: 800))
path.addLine(to: CGPoint(x: 46, y: 800))
path.addLine(to: CGPoint(x: 46, y: 793))
path.addLine(to: CGPoint(x: 60, y: 793))
path.addLine(to: CGPoint(x: 60, y: 730))
path.addLine(to: CGPoint(x: 70, y: 730))
path.addLine(to: CGPoint(x: 70, y: 673))
path.addLine(to: CGPoint(x: 103, y: 705))
path.addLine(to: CGPoint(x: 103, y: 800))
path.addLine(to: CGPoint(x: 82, y: 800))
path.addLine(to: CGPoint(x: 82, y: 780))
path.addLine(to: CGPoint(x: 133, y: 780))
path.addLine(to: CGPoint(x: 133, y: 800))
path.addLine(to: CGPoint(x: 116, y: 800))
path.addLine(to: CGPoint(x: 116, y: 718))
path.addLine(to: CGPoint(x: 126, y: 718))
path.addLine(to: CGPoint(x: 126, y: 690))
path.addLine(to: CGPoint(x: 136, y: 690))
path.addLine(to: CGPoint(x: 136, y: 660))
path.addLine(to: CGPoint(x: 145, y: 660))
path.addLine(to: CGPoint(x: 145, y: 649))
path.addLine(to: CGPoint(x: 150, y: 649))
path.addLine(to: CGPoint(x: 150, y: 630))
path.addLine(to: CGPoint(x: 150, y: 649))
path.addLine(to: CGPoint(x: 157, y: 649))
path.addLine(to: CGPoint(x: 157, y: 680))
path.addLine(to: CGPoint(x: 164, y: 680))
path.addLine(to: CGPoint(x: 164, y: 695))
path.addLine(to: CGPoint(x: 170, y: 695))
path.addLine(to: CGPoint(x: 170, y: 712))
path.addLine(to: CGPoint(x: 180, y: 712))
path.addLine(to: CGPoint(x: 180, y: 800))
path.addLine(to: CGPoint(x: 159, y: 800))
path.addLine(to: CGPoint(x: 159, y: 765))
path.addLine(to: CGPoint(x: 193, y: 765))
path.addLine(to: CGPoint(x: 193, y: 683))
path.addLine(to: CGPoint(x: 202, y: 683))
path.addLine(to: CGPoint(x: 202, y: 673))
path.addLine(to: CGPoint(x: 211, y: 673))
path.addLine(to: CGPoint(x: 211, y: 683))
path.addLine(to: CGPoint(x: 237, y: 683))
path.addLine(to: CGPoint(x: 237, y: 800))
path.addLine(to: CGPoint(x: 225, y: 800))
path.addLine(to: CGPoint(x: 225, y: 780))
path.addLine(to: CGPoint(x: 247, y: 780))
path.addLine(to: CGPoint(x: 247, y: 759))
path.addLine(to: CGPoint(x: 258, y: 759))
path.addLine(to: CGPoint(x: 258, y: 725))
path.addLine(to: CGPoint(x: 265, y: 725))
path.addLine(to: CGPoint(x: 265, y: 705))
path.addLine(to: CGPoint(x: 278, y: 705))
path.addLine(to: CGPoint(x: 278, y: 695))
path.addLine(to: CGPoint(x: 286, y: 687))
path.addLine(to: CGPoint(x: 286, y: 661))
path.addLine(to: CGPoint(x: 286, y: 687))
path.addLine(to: CGPoint(x: 294, y: 695))
path.addLine(to: CGPoint(x: 294, y: 705))
path.addLine(to: CGPoint(x: 302, y: 705))
path.addLine(to: CGPoint(x: 302, y: 725))
path.addLine(to: CGPoint(x: 312, y: 725))
path.addLine(to: CGPoint(x: 312, y: 800))
path.addLine(to: CGPoint(x: 292, y: 800))
path.addLine(to: CGPoint(x: 292, y: 780))
path.addLine(to: CGPoint(x: 319, y: 755))
path.addLine(to: CGPoint(x: 319, y: 725))
path.addLine(to: CGPoint(x: 340, y: 725))
path.addLine(to: CGPoint(x: 352, y: 712))
path.addLine(to: CGPoint(x: 362, y: 712))
path.addLine(to: CGPoint(x: 362, y: 760))
path.addLine(to: CGPoint(x: 374, y: 760))
path.addLine(to: CGPoint(x: 374, y: 743))
path.addLine(to: CGPoint(x: 379, y: 743))
path.addLine(to: CGPoint(x: 379, y: 723))
path.addLine(to: CGPoint(x: 379, y: 743))
path.addLine(to: CGPoint(x: 384, y: 743))
path.addLine(to: CGPoint(x: 384, y: 770))
path.addLine(to: CGPoint(x: 396, y: 770))
path.addLine(to: CGPoint(x: 395, y: 800))
path.addLine(to: CGPoint(x: 414, y: 800))

//建立城市輪廓的layer
let layer = CAShapeLayer()
//設定輪廓
layer.path = path.cgPath
//只有外框線條裡面不著色
layer.fillColor = UIColor.clear.cgColor
//設定外框顏色和外框寬度
layer.strokeColor = UIColor.white.cgColor
layer.lineWidth = 3

//建立讓外框自己畫的動畫效果
//keyPath參數設定strokeEnd就是畫輪廓的動畫
let animation = CABasicAnimation(keyPath: "strokeEnd")
//fromValue和toValue設定從頭畫到尾
animation.fromValue = 0
animation.toValue = 1
//duration設定15秒內畫完
animation.duration = 15
//repeatCount無限循環
animation.repeatCount = .greatestFiniteMagnitude
//將建築物輪廓加上動畫效果
layer.add(animation, forKey: nil)

view.layer.addSublayer(layer)
}

一樣在viewDidLoad()中呼叫程式。

相當療癒

用粒子發射製作燈火輝煌的效果

再來是製作粒子發射的效果。其實一開始沒什麼感覺,甚至會擔心加上去會讓畫面太雜亂花俏,不過後來調整效果後居然可以增添都會燈火煇煌的的氛圍。

加入粒子效果的教學請參考這篇文章說明:

我的粒子texture是選spark,在粒子檔調整了以下參數:

  • Emitter:birthrate為每秒產生的粒子數量。
  • Lifetime:Start為粒子可以存在的秒數;Range為能容許的時間差。
  • Alpha:粒子的透明度或濃淡度,Start是出現的時候的樣子,Range為可容許的差值,Speed為每秒降低的濃度。
  • Scale:粒子的大小,Start是產生時的大小比例,Range為可容許差值,Speed為每秒暈開的速率。
  • Color Ramp:粒子的顏色分佈,除了可以設定粒子的顏色,也可以調配不同顏色粒子數量的分佈。

相關參數說明可以參考官方文件:

然後設定好粒子效果後再於viewDidLoad加入以下程式碼就好了。

//建立用來放粒子動畫的view
let skView = SKView(frame: view.frame)
//希望加了粒子效果後仍可以看見漸層背景一定要設定這個!
skView.allowsTransparency = true
//將這個view插在漸層背景之上,建築物之下,所以設定1
view.insertSubview(skView, at: 1)
//建立顯示粒子動畫的scene
let scene = SKScene(size: skView.frame.size)
//讓粒子動畫view播放這個scene
skView.presentScene(scene)
//用設定好的粒子檔來建立可用scene播放的粒子動畫物件
let emitterNode = SKEmitterNode(fileNamed: "MyParticle")
//將粒子動畫物件交給scene
scene.addChild(emitterNode!)
//設定scene播放的範圍為整個view
scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
//設定粒子動畫的背景為透明,不要遮到漸層背景
scene.backgroundColor = UIColor.clear

加入音樂

加入音樂需要import AVFoundation,但我希望可以做到重複播放不間斷。詳細教學請參考此篇文章:

先在viewDidLoad()前建立好循環器(AVPlayerLooper):

var looper: AVPlayerLooper?
//循環播放器要在viewDidLoad之前宣告,如果於viewDidLoad裡面宣告會造成viewDidLoad程式跑完被清掉造成無法播放

然後再於viewDidLoad()裡面撰寫播放的程式:

//將背景音樂檔轉換成file path
let fileUrl = Bundle.main.url(forResource: "bensound-perception", withExtension: "mp3")!
//生成背景音樂item
let backgroundMusic = AVPlayerItem(url: fileUrl)
//建立可以播放多首歌的AVQueuePlayer
let player = AVQueuePlayer()
//將上面的player交給循環播放器可以重複播放同一首歌曲
looper = AVPlayerLooper(player: player, templateItem: backgroundMusic)
player.play()

音檔來源:

製作斜邊的ImageView

首先要創建一個可以繼承ImageView的新元件類別,並將這個自訂元件類別的輪廓畫成希望呈現的形狀。詳細說明可參考此篇:

接著在storyboard上加入imageView元件並設定好圖片,在identity inspector區塊設定class為自訂的元件類別,模擬時就能看到斜邊效果。

加入斜邊後讓畫面不會淪於過於死板的水平排列

腳印gif

最後就是在毛玻璃右上角加上一個腳印的gif,先上網抓一個腳印的gif轉換成分解png圖檔加到asset裡,接著就能用imageView做成類似gif的效果啦!

//設定位置和大小
let footprintImageView = UIImageView(frame: CGRect(x: 300, y: 470, width: 60, height: 60))
view.addSubview(footprintImageView)
//建立輪播圖片
let animatedImage = UIImage.animatedImageNamed("aa66851a2c8a4d54fb14d494041a6a93aEzudLNNwugVAhzW-", duration: 5)
//將輪播圖片放到imageView裡
footprintImageView.image = animatedImage
//每個圖檔大小不一,但不希望腳丫變形
footprintImageView.contentMode = .scaleAspectFit

一直沒有聽到有音樂的版本很扼腕吧?附上完成作品的影片:

--

--