iPod classicのUIを再現する
Published by @SoNiceInfo at 6/24/2020
少し前にiPod classic風の音楽再生アプリが話題になりましたが、App Storeへの登録を削除されて使えなくなってしまいました。
それならば、自分で、せっかくならSwiftUIで作ってしまおうということで、まずはiPod classicのUIを再現します。
今回の目標
再現するのは自分も使っていた第5世代のiPodです。
そして今回再現するゴールはこちらです。(音楽再生機能の実装は未定です。) クリックホイールをクルクルしたときの気持ちいいクリック音も再現します。
- ライトモードで白いiPod, ダークモードで黒いiPodにする
- ディスプレイとクリックホイールを再現する
- クリックホイールに連動してディスプレイの選択項目を変更させる
成果物
このページの成果物は以下のイメージです。
白と黒のiPodを作る
第5世代iPod classicは白と黒の2色展開でしたので、2色用意します。
ライトモードで白いiPod, ダークモードで黒いiPodになるように実装します。
ライト、ダークモード対応のカラーセットを作成する
ライト、ダークモードに対応するにはAssets.xassets
内で独自のカラーセットを用意する必要があります。
プロジェクトナビゲーター(フォルダペイン)からAssets.xassets
を選択して、AppIconの下辺りの余白部分で右クリックをして、New Color Set
を選択します。
インスペクトペイン(右側のペイン)でAppearancesをAny, Light, Dark
に変更します。
これで準備は完了です。
本体とホイールの色を作成する
画面中央のAny Appearance, Light Appearance, Dark Appearanceをそれぞれ選択してインスペクトペイン(右側のペイン)のColorのところで色を作成します。
本体色はAny Appearance, Light Appearanceで白とDark Appearanceで黒にします。
ホイールの色はAny Appearance, Light Appearanceで明るいグレーとDark Appearanceで暗いグレーにします。
作成した色を使う
Assets.xassets
で作成した色はColor("")
で使うことができます。
今回の場合だとColor("shell")
, Color("wheel")
です。
大枠を作る - ContentView
メニューを作る
まずは表示するメニューを作ります。
メニューはIdentifiableプロトコルに準拠した構造体で、id(Int型), name(String型), next(Bool型、次の項目の有無)を持つものとします。
次のメニューの表示を考えて作り込んでいくと、プロパティは違うものになってくるかもしれませんが、いったんこのようにします。
さらに、メニューの何番目の項目を選択しているか管理できるように変数menuIndex
を用意します。
@Stateで変数をラップしてあげることで変更があった際のViewへの再描画通知機能をSwiftUIへまかせます。
//
// ContentView.swift
//
import SwiftUI
struct Menu: Identifiable {
let id: Int
let name: String
let next: Bool
}
struct ContentView: View {
@State private var menus: [Menu] = [
Menu(id: 0, name: "Music", next: true),
Menu(id: 1, name: "Photos", next: true),
Menu(id: 2, name: "Videos", next: true),
Menu(id: 3, name: "Extras", next: true),
Menu(id: 4, name: "Settings", next: true),
Menu(id: 5, name: "Shuffle Songs", next: false)
]
@State private var menuIndex : Int = 0
var body: some View {
VStack () {
Spacer()
DisplayView(menus: self.$menus, menuIndex: self.$menuIndex)
Spacer()
WheelView(menus: self.$menus, menuIndex: self.$menuIndex)
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
現時点ではDisplayViewとWheelViewを作成していないのでエラーになります。
変数menus
とmenuIndex
をDisplayViewとWheelViewにわたすことで、その中で共通の変数を参照、変更することができます。
WheelViewの中でmenuIndex
を変更するとDisplayViewにも変更通知が行き、DisplayViewのメニュー表示が変化するという仕組みです。
ディスプレイを作る - DisplayView
DisplayViewを作る上で、iPod classicのメニュー表示の要件をまとめます。
- 枠ありで背景が白く少し角丸
- タイトルバーがグレーでiPodと表示
- メニューを一覧表示
- 選択された項目は背景が青で文字が白
- 次のメニューがある場合は右に矢印
要件がまとまったのでひとつずつ実装していきます
枠ありで背景が白く少し角丸
GeometryReader { geometry in
VStack (alignment: .leading, spacing: 0) {
// これから実装
}
.background(Color.white)
.frame(width: geometry.size.width * 0.95, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5).stroke(lineWidth: 2).foregroundColor(Color.gray))
}
ディスプレイの中身はVStack
で構成していきます。.background()
で背景を白にします。.frame()
でディスプレイサイズを指定します。
画面サイズの取得にGeometryReader
を使います。geometry.size.width
で画面の横幅が取得できるのでその95%くらいにします。縦幅は300で固定です。
枠ありの角丸を実装するには少しコツがあります。
まず、.clipShape(RoundedRectangle(cornerRadius: 5))
で、角丸四角の形にVStack
を切り抜きます。
そのうえで、.overlay(RoundedRectangle(cornerRadius: 5).stroke(lineWidth: 2).foregroundColor(Color.gray))
で、 同じ角丸サイズの角丸四角の枠のみのものをオーバーレイ、つまり上からかぶせます。
タイトルバーがグレーでiPodと表示
Text("iPod")
.font(.system(size: 25))
.frame(width: geometry.size.width * 0.95, height: 30.0)
.background(Color.gray)
ここからは先程の// これから実装
の部分を実装していきます。 タイトルバーはText
でiPodと表示します。.font()
でフォントサイズは25, .background()
で背景はグレーに指定します。
このままでは背景色の有効範囲が文字の部分のみとなってしまいますので.frame(width: geometry.size.width * 0.95, height: 30.0)
で横幅を指定します。
メニューを一覧表示
ForEach(self.menus) { menu in
HStack () {
Text(menu.name)
.font(.system(size: 25))
Spacer()
if (menu.next){
Image(systemName: "chevron.right")
}
}
.padding(.horizontal, 5)
.foregroundColor(menu.id == self.menuIndex ? .white : .black)
.background(menu.id == self.menuIndex ? Color.blue : Color.white)
}
メニューの中身を実装します。
ContentViewから表示するメニューを変数menus
で受け取っているので、ForEach(menus) { menu in ... }
でmenu
としてひとつずつ項目を処理します。HStack
で項目名と次がある場合の矢印を横並びで表示できるようにします。
選択された項目は背景が青で文字が白
menu.id == self.menuIndex
、すなわち、「メニューが選択されているべきか」で分岐するif文を用意します。
文字色は.foregroundColor(menu.id == self.menuIndex ? .white : .black)
背景色は.background(menu.id == self.menuIndex ? Color.blue : Color.white)
で変更しています
次のメニューがある場合は右に矢印
menu.next
がtrueならImage(systemName: "chevron.right")
を表示するようにします。
右矢印はSF Symbolsを使っています。
SF SymbolsについてはSF Symbolsを使うで解説しています。
最終的なDisplayView
//
// DisplayView.swift
//
import SwiftUI
struct DisplayView: View {
@Binding var menus: [Menu]
@Binding var menuIndex: Int
var body: some View {
GeometryReader { geometry in
VStack (alignment: .leading, spacing: 0) {
Text("iPod")
.font(.system(size: 25))
.frame(width: geometry.size.width * 0.95, height: 30.0)
.background(Color.gray)
ForEach(self.menus) { menu in
HStack () {
Text(menu.name)
.font(.system(size: 25))
Spacer()
if (menu.next){
Image(systemName: "chevron.right")
}
}
.padding(.horizontal, 5)
.foregroundColor(menu.id == self.menuIndex ? .white : .black)
.background(menu.id == self.menuIndex ? Color.blue : Color.white)
}
Spacer()
}
.background(Color.white)
.frame(width: geometry.size.width * 0.95, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5).stroke(lineWidth: 2).foregroundColor(Color.gray))
}
}
}
struct DisplayView_Previews: PreviewProvider {
@State static var menus: [Menu] = [
Menu(id: 0, name: "Music", next: true),
Menu(id: 1, name: "Photos", next: true),
]
@State static var menuIndex : Int = 0
static var previews: some View {
DisplayView(menus: $menus, menuIndex: $menuIndex)
}
}
クリックホイールを作る - WheelView
クリックホイールの要件をまとめます。
- ホイール部分とタップ部分の2重丸構造
- 上下左右にMENUや再生ボタン等
- 中央をクリックすると次のメニューへ移動(ここではタップのみ実装)
- ホイール部分をクルクルして上下の移動
ホイール部分とタップ部分の2重丸構造
GeometryReader { geometry in
Circle()
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
.foregroundColor(Color("wheel"))
.overlay(
Circle()
.frame(width: geometry.size.width * 0.35, height: geometry.size.width * 0.35)
.foregroundColor(Color("shell"))
})
まず、Circle().frame()
で、画面の横幅90%のサイズの円を用意します。これがホイール部分になります。
次に、.overlay()
を使って画面の横幅35%のサイズの円をかぶせます。これがタップ部分になります。
色は独自に作成したライト、ダークモードに対応したカラーセットを使います。
これでホイール部分とタップ部分の2重丸構造の、クリックホイールができあがりました。
上下左右にMenuや再生ボタン等
GeometryReader { geometry in
ZStack () {
Circle()
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
.foregroundColor(Color("wheel"))
)
.overlay(
Circle()
.frame(width: geometry.size.width * 0.35, height: geometry.size.width * 0.35)
.foregroundColor(Color("shell"))
)
Text("MENU")
.font(.title)
.foregroundColor(.white)
.offset(y: -140)
Image(systemName: "playpause.fill")
.font(.title)
.foregroundColor(.white)
.offset(y: 140)
Image(systemName: "forward.end.alt.fill")
.font(.title)
.foregroundColor(.white)
.offset(x: 140)
Image(systemName: "backward.end.alt.fill")
.font(.title)
.foregroundColor(.white)
.offset(x: -140)
}
}
さきほど作ったクリックホイールをZStack
に入れてMENUや再生ボタン等を上にかぶせていきます。
MENUはText
, 再生ボタン等はSF Symbolsを使います。
それぞれクリックホイールの上下左右に配置されるようにoffset
で場所をずらします。
中央をクリックすると次のメニューへ移動(ここではタップのみ実装)
import AVFoundation
..省略..
Circle()
.frame(width: geometry.size.width * 0.35, height: geometry.size.width * 0.35)
.foregroundColor(Color("shell"))
.gesture(
TapGesture(count: 1)
.onEnded {
AudioServicesPlaySystemSound (1104)
print("Tapped")
}
)
クリックホイールの「クリック」を実装します。
今回はiPod classicのUIを再現するのが目的なので、「タップしたら何かアクションを起こせるようにする」ところまで実装します。.overay()
でかぶせた円に対して.gesture()
, TapGesture()
を使ってタップでアクションを起こせるようにします。TapGesture(count: 1)
のcountは「その回数タップされたら次のアクションを起こす」という意味です。今回は1です。.onEnded
でタップが終わったら起こすアクションを指定します。
今回はXcode上にTappedと表示するだけです。
Appleからデフォルトで提供されているシステム音を使ってクリック音を鳴らします。AVFoundation
をimportしてAudioServicesPlaySystemSound()
で呼び出します。
自分はこちらのレポジトリを参考にしました。TUNER88/iOSSystemSoundsLibrary: List of all system sounds used in iOS
ホイール部分をクルクルして上下の移動
import AVFoundation
..省略..
@State private var lastAngle: CGFloat = 0
@State private var counter: CGFloat = 0
..省略..
Circle()
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
.foregroundColor(Color("wheel"))
.gesture(DragGesture()
.onChanged{ v in
// 回転角を計算する
var angle = atan2(v.location.x - geometry.size.width * 0.9 / 2, geometry.size.width * 0.9 / 2 - v.location.y) * 180 / .pi
if (angle < 0) { angle += 360 }
// 回転差分を計算する
let theta = self.lastAngle - angle
self.lastAngle = angle
// 少ない回転角を積み上げていく
if (abs(theta) < 20) {
self.counter += theta
}
// counterが20以上(以下)になったらメニューを移動
if (self.counter > 20 && self.menuIndex > 0) {
self.menuIndex -= 1
AudioServicesPlaySystemSound (1104)
} else if (self.counter < -20 && self.menuIndex < self.menus.count - 1) {
self.menuIndex += 1
AudioServicesPlaySystemSound (1104)
}
if (abs(self.counter) > 20) { self.counter = 0 }
}
.onEnded { v in
self.counter = 0
}
)
ホイール部分ををクルクルしたらメニューの選択項目が変わるようにします。
大まかな流れは「クルクルした分の回転角を計算する」「直前の回転からの差分を計算する」「差分の回転角が少なかった場合のみ回転角を積み上げていく」「回転角が20積み上がったらメニューを下に移動、-20積み上がったら上に移動」です。
回転角についてはDragGestureで回転させるで解説しているので参考にしてください。
「差分の回転角が少なかった場合のみ回転角を積み上げていく」理由については回転角が0度から360度の間をまたぐときに、メニューが異常な動きをするのでそれを防いでいます。counter
の20という値は感覚です。counter
が20(-20)になったらmenuIndexを1(-1)動かして選択項目を変更します。
タップしたときと同様AudioServicesPlaySystemSound (1104)
でクリック音を鳴らします。 クルクルのジェスチャーが終わったらcounter
の値を0に戻します。
最終的なWheelView
//
// WheelView.swift
//
import SwiftUI
import AVFoundation
struct WheelView: View {
@Binding var menus: [Menu]
@Binding var menuIndex: Int
@State private var lastAngle: CGFloat = 0
@State private var counter: CGFloat = 0
var body: some View {
GeometryReader { geometry in
ZStack () {
Circle()
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
.foregroundColor(Color("wheel"))
.gesture(DragGesture()
.onChanged{ v in
// 回転角を計算する
var angle = atan2(v.location.x - geometry.size.width * 0.9 / 2, geometry.size.width * 0.9 / 2 - v.location.y) * 180 / .pi
if (angle < 0) { angle += 360 }
// 回転差分を計算する
let theta = self.lastAngle - angle
self.lastAngle = angle
// 少ない回転角のものを積み上げていく
if (abs(theta) < 20) {
self.counter += theta
}
// counterが20以上(以下)になったらメニューを移動
if (self.counter > 20 && self.menuIndex > 0) {
self.menuIndex -= 1
AudioServicesPlaySystemSound (1104)
} else if (self.counter < -20 && self.menuIndex < self.menus.count - 1) {
self.menuIndex += 1
AudioServicesPlaySystemSound (1104)
}
if (abs(self.counter) > 20) { self.counter = 0 }
}
.onEnded { v in
self.counter = 0
}
)
.overlay(
Circle()
.frame(width: geometry.size.width * 0.35, height: geometry.size.width * 0.35)
.foregroundColor(Color("shell"))
.gesture(
TapGesture(count: 1)
.onEnded {
AudioServicesPlaySystemSound (1104)
print("Tapped")
}
)
)
Text("MENU")
.font(.title)
.foregroundColor(.white)
.offset(y: -140)
Image(systemName: "playpause.fill")
.font(.title)
.foregroundColor(.white)
.offset(y: 140)
Image(systemName: "forward.end.alt.fill")
.font(.title)
.foregroundColor(.white)
.offset(x: 140)
Image(systemName: "backward.end.alt.fill")
.font(.title)
.foregroundColor(.white)
.offset(x: -140)
}
}
}
}
struct WheelView_Previews: PreviewProvider {
@State static var menus: [Menu] = [
Menu(id: 0, name: "Music", next: true),
Menu(id: 1, name: "Photos", next: true),
]
@State static var menuIndex : Int = 0
static var previews: some View {
WheelView(menus: $menus, menuIndex: $menuIndex)
}
}
まとめ
とても長くなってしまいましたが、構成要素はContentView, DisplayView, WheelViewの3つです。
GitHub(https://github.com/d1v1b/iPodClassicUI)に今回のコードをあげているので遊んでみてください。
変更点
2020/04/21 画面サイズの取得にUIScreenではなくGeometryReaderを使うようにしました。