iPod classicのUIを再現する

Published by @SoNiceInfo at 6/24/2020


SwiftUIでiPod classicのUIを再現する

少し前にiPod classic風の音楽再生アプリが話題になりましたが、App Storeへの登録を削除されて使えなくなってしまいました。
それならば、自分で、せっかくならSwiftUIで作ってしまおうということで、まずはiPod classicのUIを再現します。

今回の目標

再現するのは自分も使っていた第5世代のiPodです。
そして今回再現するゴールはこちらです。(音楽再生機能の実装は未定です。) クリックホイールをクルクルしたときの気持ちいいクリック音も再現します。

  1. ライトモードで白いiPod, ダークモードで黒いiPodにする
  2. ディスプレイとクリックホイールを再現する
  3. クリックホイールに連動してディスプレイの選択項目を変更させる

成果物

このページの成果物は以下のイメージです。

白と黒のiPodを作る

第5世代iPod classicは白と黒の2色展開でしたので、2色用意します。
ライトモードで白いiPod, ダークモードで黒いiPodになるように実装します。

ライト、ダークモード対応のカラーセットを作成する

Assets.xassetsを選択して余白部分で右クリックをして、New Color Setを選択する

ライト、ダークモードに対応するには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を作成していないのでエラーになります。
変数menusmenuIndexをDisplayViewとWheelViewにわたすことで、その中で共通の変数を参照、変更することができます。
WheelViewの中でmenuIndexを変更するとDisplayViewにも変更通知が行き、DisplayViewのメニュー表示が変化するという仕組みです。

ディスプレイを作る - DisplayView

DisplayViewを作る上で、iPod classicのメニュー表示の要件をまとめます。

  • 枠ありで背景が白く少し角丸
  • タイトルバーがグレーでiPodと表示
  • メニューを一覧表示
  • 選択された項目は背景が青で文字が白
  • 次のメニューがある場合は右に矢印
ipod classicのディスプレイをSwiftUIで再現する

要件がまとまったのでひとつずつ実装していきます

枠ありで背景が白く少し角丸

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や再生ボタン等
  • 中央をクリックすると次のメニューへ移動(ここではタップのみ実装)
  • ホイール部分をクルクルして上下の移動
ipod classicのクリックホイールをSwiftUIで再現する

ホイール部分とタップ部分の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を使うようにしました。



    アプリをリリースしました!

    ToDoアプリ

    iCloudを利用したデバイス間データ共有、ダークモードに対応しています。

    リリースしたToDoアプリのスクリーンショット

    IPアドレス履歴保存アプリ

    取得したIPアドレスを確認、位置情報とともに保存できます。

    リリースしたIPアドレス保存アプリのスクリーンショット