Develop Apps for iOSをやってみる 〜その7〜
はじめに
DX推進室の野上です。
前回 の続きです。
どこから?
今回はここから続けます。
最初に
今回も用意されたプロジェクトファイルがあります。
今回はそのプロジェクトがないと完成しませんので、絶対にダウンロードして使ってください。
状態管理とライフサイクル
オーバーレイViewを作成する
ミーティングタイマー画面をZStackを使ってブラッシュアップします。
VStack,HStackと出てきましたが、ZStackはZ軸方向に積み上げることができます。
MeetingView.swiftに修正をしていきましょう。
VStackをZStackで囲みます。また、VStackにつけていた.padding()をZStackに移動します。
RoundedRectangleで長方形を描画し、scrum.colorで塗り潰します。
scrumが定義されていないので、@Bindingをつけて定義しておきましょう。
MeetingView_Previewsの呼び出しも合わせて修正します。
import SwiftUI
struct MeetingView: View {
@Binding var scrum: DailyScrum
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16.0)
.fill(scrum.color)
VStack {
ProgressView(value: 5, total: 15)
HStack {
VStack(alignment: .leading) {
Text("Seconds Elapsed")
.font(.caption)
Label("300", systemImage: "hourglass.bottomhalf.fill")
}
Spacer()
VStack(alignment: .trailing) {
Text("Seconds Remaining")
.font(.caption)
Label("600", systemImage: "hourglass.tophalf.fill")
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Time remaining"))
.accessibilityValue(Text("10 minutes"))
Circle()
.strokeBorder(lineWidth: 24, antialiased: true)
HStack {
Text("Speaker 1 of 3")
Spacer()
Button(action: {}) {
Image(systemName: "forward.fill")
}
.accessibilityLabel(Text("Next speaker"))
}
}
}
.padding()
}
}
struct MeetingView_Previews: PreviewProvider {
static var previews: some View {
MeetingView(scrum: .constant(DailyScrum.data[0]))
}
}
このままではbuildエラーになるのでDetailView.swiftでMeetingViewを呼び出しているところに修正を入れます。
import SwiftUI
struct DetailView: View {
@Binding var scrum: DailyScrum
@State private var data: DailyScrum.Data = DailyScrum.Data()
@State private var isPresented = false
var body: some View {
List {
Section(header: Text("Meeting Info")) {
NavigationLink(
destination: MeetingView(scrum: $scrum)) {
Label("Start Meeting", systemImage: "timer")
.font(.headline)
.foregroundColor(.accentColor)
.accessibilityLabel(Text("Start meeting"))
}
HStack {
Label("Length", systemImage: "clock")
.accessibilityLabel(Text("Meeting length"))
Spacer()
Text("\(scrum.lengthInMinutes) minutes")
}
HStack {
Label("Color", systemImage: "paintpalette")
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundColor(scrum.color)
}
.accessibilityElement(children: .ignore)
}
Section(header: Text("Attendees")) {
ForEach(scrum.attendees, id: \.self) { attendee in
Label(attendee, systemImage: "person")
.accessibilityLabel(Text("Person"))
.accessibilityValue(Text(attendee))
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarItems(trailing: Button("Edit") {
isPresented = true
data = scrum.data
})
.navigationTitle(scrum.title)
.fullScreenCover(isPresented: $isPresented) {
NavigationView {
EditView(scrumData: $data)
.navigationTitle(scrum.title)
.navigationBarItems(leading: Button("Cancel") {
isPresented = false
}, trailing: Button("Done") {
isPresented = false
scrum.update(from: data)
})
}
}
}
}
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
DetailView(scrum: .constant(DailyScrum.data[0]))
}
}
}
ミーティングヘッダーを作成する
MeetingViewからヘッダー部分をMeetingHeaderViewとして抽出します。
ファイル名はMeetingHeaderView.swiftにしましょう。
import SwiftUI
struct MeetingHeaderView: View {
var body: some View {
Text("Hello, World!")
}
}
struct MeetingHeaderView_Previews: PreviewProvider {
static var previews: some View {
MeetingHeaderView()
}
}
MeetingViewのProgressViewからaccessibilityValue(Text(“10 minutes”))
までを
MeetingHeaderViewのbodyの中にコピーしましょう。
またMeetingHeaderView_Previewsには.previewLayout(.sizeThatFits)をつけておきましょう。
import SwiftUI
struct MeetingHeaderView: View {
var body: some View {
ProgressView(value: 5, total: 15)
HStack {
VStack(alignment: .leading) {
Text("Seconds Elapsed")
.font(.caption)
Label("300", systemImage: "hourglass.bottomhalf.fill")
}
Spacer()
VStack(alignment: .trailing) {
Text("Seconds Remaining")
.font(.caption)
Label("600", systemImage: "hourglass.tophalf.fill")
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Time remaining"))
.accessibilityValue(Text("10 minutes"))
}
}
struct MeetingHeaderView_Previews: PreviewProvider {
static var previews: some View {
MeetingHeaderView()
.previewLayout(.sizeThatFits)
}
}
ProgressViewとHStackをVStackで囲って、HStsckについているaccessibility modifiersをVStackに移動しましょう。
import SwiftUI
struct MeetingHeaderView: View {
var body: some View {
VStack {
ProgressView(value: 5, total: 15)
HStack {
VStack(alignment: .leading) {
Text("Seconds Elapsed")
.font(.caption)
Label("300", systemImage: "hourglass.bottomhalf.fill")
}
Spacer()
VStack(alignment: .trailing) {
Text("Seconds Remaining")
.font(.caption)
Label("600", systemImage: "hourglass.tophalf.fill")
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Time remaining"))
.accessibilityValue(Text("10 minutes"))
}
}
struct MeetingHeaderView_Previews: PreviewProvider {
static var previews: some View {
MeetingHeaderView()
.previewLayout(.sizeThatFits)
}
}
ProgressViewを固定値から実際に計算する処理を追加します。
accessibilityValueも固定値から実値に変更します。
import SwiftUI
struct MeetingHeaderView: View {
let secondsElapsed: Int
let secondsRemaining: Int
private var progress: Double {
guard secondsRemaining > 0 else { return 1 }
let totalSeconds = Double(secondsElapsed + secondsRemaining)
return Double(secondsElapsed) / totalSeconds
}
private var minutesRemaining: Int {
secondsRemaining / 60
}
private var minutesRemainingMetric: String {
minutesRemaining == 1 ? "minute" : "minutes"
}
var body: some View {
VStack {
ProgressView(value: progress)
HStack {
VStack(alignment: .leading) {
Text("Seconds Elapsed")
.font(.caption)
Label("\(secondsElapsed)", systemImage: "hourglass.bottomhalf.fill")
}
Spacer()
VStack(alignment: .trailing) {
Text("Seconds Remaining")
.font(.caption)
Label("\(secondsRemaining)", systemImage: "hourglass.tophalf.fill")
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Time remaining"))
.accessibilityValue(Text("\(minutesRemaining) \(minutesRemainingMetric)"))
}
}
struct MeetingHeaderView_Previews: PreviewProvider {
static var previews: some View {
MeetingHeaderView(secondsElapsed: 60, secondsRemaining: 180)
.previewLayout(.sizeThatFits)
}
}
ミーティングヘッダーにデザイン要素を追加する
MeetingHeaderViewのデザインを整えます。
import SwiftUI
struct MeetingHeaderView: View {
let secondsElapsed: Int
let secondsRemaining: Int
private var progress: Double {
guard secondsRemaining > 0 else { return 1 }
let totalSeconds = Double(secondsElapsed + secondsRemaining)
return Double(secondsElapsed) / totalSeconds
}
private var minutesRemaining: Int {
secondsRemaining / 60
}
private var minutesRemainingMetric: String {
minutesRemaining == 1 ? "minute" : "minutes"
}
let scrumColor: Color
var body: some View {
VStack {
ProgressView(value: progress)
.progressViewStyle(ScrumProgressViewStyle(scrumColor: scrumColor))
HStack {
VStack(alignment: .leading) {
Text("Seconds Elapsed")
.font(.caption)
Label("\(secondsElapsed)", systemImage: "hourglass.bottomhalf.fill")
}
Spacer()
VStack(alignment: .trailing) {
Text("Seconds Remaining")
.font(.caption)
HStack {
Text("\(secondsRemaining)")
Image(systemName: "hourglass.tophalf.fill")
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Time remaining"))
.accessibilityValue(Text("\(minutesRemaining) \(minutesRemainingMetric)"))
.padding([.top, .horizontal])
}
}
struct MeetingHeaderView_Previews: PreviewProvider {
static var previews: some View {
MeetingHeaderView(secondsElapsed: 60, secondsRemaining: 180, scrumColor: DailyScrum.data[0].color)
.previewLayout(.sizeThatFits)
}
}
このコードの .progressViewStyle は最初にダウンロードしたプロジェクトの中に定義されていますので
必ずプロジェクトファイルを使ってください。
ここまででプレビューはこんな感じになります。
状態オブジェクトを追加する
時間を計測するscrumTimerを @StateObject で定義します。
ScrumTimerクラスはダウンロードしたプロジェクトファイルの中に定義されています。
MeetingViewに前項までに切り出したMeetingHeaderViewを追加するとともに
scrumTimerを追加定義しましよう。
import SwiftUI
struct MeetingView: View {
@Binding var scrum: DailyScrum
@StateObject var scrumTimer = ScrumTimer()
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16.0)
.fill(scrum.color)
VStack {
MeetingHeaderView(secondsElapsed: scrumTimer.secondsElapsed, secondsRemaining: scrumTimer.secondsRemaining, scrumColor: scrum.color)
Circle()
.strokeBorder(lineWidth: 24, antialiased: true)
HStack {
Text("Speaker 1 of 3")
Spacer()
Button(action: {}) {
Image(systemName:"forward.fill")
}
.accessibilityLabel(Text("Next speaker"))
}
}
}
.padding()
.foregroundColor(scrum.color.accessibleFontColor)
}
}
struct MeetingView_Previews: PreviewProvider {
static var previews: some View {
MeetingView(scrum: .constant(DailyScrum.data[0]))
}
}
MeetingViewの画面は以下のようになります。
ライフサイクルイベントを追加する
SwiftUIはViewの表示・削除イベントを通知するライフサイクルメソッドを提供しています。
onAppear()とonDisappear()がそれに当たります。
本項ではライフサイクルに添って、ScrumTimerクラスのreset,start,stopを追加します。
import SwiftUI
struct MeetingView: View {
@Binding var scrum: DailyScrum
@StateObject var scrumTimer = ScrumTimer()
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16.0)
.fill(scrum.color)
VStack {
MeetingHeaderView(secondsElapsed: scrumTimer.secondsElapsed, secondsRemaining: scrumTimer.secondsRemaining, scrumColor: scrum.color)
Circle()
.strokeBorder(lineWidth: 24, antialiased: true)
HStack {
Text("Speaker 1 of 3")
Spacer()
Button(action: {}) {
Image(systemName:"forward.fill")
}
.accessibilityLabel(Text("Next speaker"))
}
}
}
.padding()
.foregroundColor(scrum.color.accessibleFontColor)
.onAppear {
scrumTimer.reset(lengthInMinutes: scrum.lengthInMinutes, attendees: scrum.attendees)
scrumTimer.startScrum()
}
.onDisappear {
scrumTimer.stopScrum()
}
}
}
struct MeetingView_Previews: PreviewProvider {
static var previews: some View {
MeetingView(scrum: .constant(DailyScrum.data[0]))
}
}
ミーティングフッターを作成する
ヘッダー部分を抽出して別クラスにしたので、フッター部分も同様に抽出しましょう。
新しくMeetingFooterView.swiftを作成します。
import SwiftUI
struct MeetingFooterView: View {
let speakers: [ScrumTimer.Speaker]
var skipAction: () -> Void
private var speakerNumber: Int? {
guard let index = speakers.firstIndex(where: { !$0.isCompleted }) else { return nil }
return index + 1
}
private var isLastSpeaker: Bool {
return speakers.dropLast().allSatisfy { $0.isCompleted }
}
private var speakerText: String {
guard let speakerNumber = speakerNumber else { return "No more speakers" }
return "Speaker \(speakerNumber) of \(speakers.count)"
}
var body: some View {
VStack {
HStack {
if isLastSpeaker {
Text("Last Speaker")
} else {
Text(speakerText)
Spacer()
Button(action: skipAction) {
Image(systemName:"forward.fill")
}
.accessibilityLabel(Text("Next speaker"))
}
}
}
.padding([.bottom, .horizontal])
}
}
struct MeetingFooterView_Previews: PreviewProvider {
static var speakers = [ScrumTimer.Speaker(name: "Kim", isCompleted: false), ScrumTimer.Speaker(name: "Bill", isCompleted: false)]
static var previews: some View {
MeetingFooterView(speakers: speakers, skipAction: {})
.previewLayout(.sizeThatFits)
}
}
MeetingView側にも修正を入れてフッターに当たる部分をMeetingFooterViewに差し替えます。
import SwiftUI
struct MeetingView: View {
@Binding var scrum: DailyScrum
@StateObject var scrumTimer = ScrumTimer()
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16.0)
.fill(scrum.color)
VStack {
MeetingHeaderView(secondsElapsed: scrumTimer.secondsElapsed, secondsRemaining: scrumTimer.secondsRemaining, scrumColor: scrum.color)
Circle()
.strokeBorder(lineWidth: 24, antialiased: true)
MeetingFooterView(speakers: scrumTimer.speakers, skipAction: scrumTimer.skipSpeaker)
}
}
.padding()
.foregroundColor(scrum.color.accessibleFontColor)
.onAppear {
scrumTimer.reset(lengthInMinutes: scrum.lengthInMinutes, attendees: scrum.attendees)
scrumTimer.startScrum()
}
.onDisappear {
scrumTimer.stopScrum()
}
}
}
struct MeetingView_Previews: PreviewProvider {
static var previews: some View {
MeetingView(scrum: .constant(DailyScrum.data[0]))
}
}
MeetingViewの画面は以下のようになります。
buildして動作を確かめてみましょう。
Seconds ElapsedとSeconds Remainingが動作しているはずです。
フッター部分の右下のアイコンでSpeakerを次に飛ばせることも確認できます。
AVFoundationを使用して音を鳴らす
AVFoundation frameworkを使用して、スピーカーが変わるたびに音を鳴らしましょう。
AVFoundationをimportして使用します。
import SwiftUI
import AVFoundation
struct MeetingView: View {
@Binding var scrum: DailyScrum
@StateObject var scrumTimer = ScrumTimer()
var player: AVPlayer { AVPlayer.sharedDingPlayer }
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16.0)
.fill(scrum.color)
VStack {
MeetingHeaderView(secondsElapsed: scrumTimer.secondsElapsed, secondsRemaining: scrumTimer.secondsRemaining, scrumColor: scrum.color)
Circle()
.strokeBorder(lineWidth: 24, antialiased: true)
MeetingFooterView(speakers: scrumTimer.speakers, skipAction: scrumTimer.skipSpeaker)
}
}
.padding()
.foregroundColor(scrum.color.accessibleFontColor)
.onAppear {
scrumTimer.reset(lengthInMinutes: scrum.lengthInMinutes, attendees: scrum.attendees)
scrumTimer.speakerChangedAction = {
player.seek(to: .zero)
player.play()
}
scrumTimer.startScrum()
}
.onDisappear {
scrumTimer.stopScrum()
}
}
}
struct MeetingView_Previews: PreviewProvider {
static var previews: some View {
MeetingView(scrum: .constant(DailyScrum.data[0]))
}
}
buildして動かしてみましょう。音が鳴るはずです。
コードを見るとスピーカーをスキップしても音が鳴るように見えますが、実際は一人分の持ち時間が経過するまで音はなりません。
(音量上げてビックリしました)
ここまでのまとめ
- @State と同じような @StateObject がある
- onAppear,onDisappearでViewが作られる時と消える時の処理を書くことができる
- AVFoundationを使って音が鳴らせる
- プロジェクトファイルはちゃんと使おう
次回は アプリのデータを更新する です。