この投稿はなに?
iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「予定表」Appプロジェクトを学ぶための解説です。
「予定表」Appプロジェクト
このプロジェクトでは、イベントを日付ごとに整理してスケジュールを立てる「DatePlanner」アプリを開発します。
このアプリでは、リストビューとオブザーバ・データモデルを使って、イベントやタスクを動的に生成するリストを構築します。
そのために、アプリのビュー階層全体から利用できる「一貫性のあるデータオブジェクト」を作成します。
リソースファイル
プロジェクトに含まれるファイルは、機能的な視点から「データモデル(イベントとタスク)、ビュー、シンボルその他」に分けて捉えることができます。
データモデル
- DatePlannerApp
- EventData
- Event
- EventTask
ビュー
- EventList
- EventRow
- EventDetail
- EventEditor
- TaskRow
シンボルその他
- SymbolPicker
- EventSymbols
- SFSymbolStyling
- ColorOption
DatePlannerApp.swift
import SwiftUI
@main
struct DatePlannerApp: App {
@StateObject private var eventData = EventData()
var body: some Scene {
WindowGroup {
NavigationView {
EventList()
Text("Select an Event")
.foregroundStyle(.secondary)
}
.environmentObject(eventData)
}
}
}
アプリ内のビュー間を往来できるようにするため、ビュー階層の最上位にナビゲーションコンテナを配置します。
このアプリを起動すると、最初にEventList
ビューがナビゲーションの起点として表示されます。
アプリに対して画面幅が十分に確保されている場合、EventList
ビューはサイドバー形式で表示されます。
ユーザがサイドバーのイベント行事を選択するまでは、Text
ビューが表示されるようにしています。
ユーザがイベント行事を選択すると、そのEventDetail
ビューがプライマリパネルに表示されます。
イベント行事のデータは、eventData
プロパティに保持されます。
このEventData
型プロパティは@StateObject
属性でマークされるので、オブザーバブル・オブジェクトのインスタンスを作成します。
したがって、この値の変化はSwiftUIフレームワークによって追跡され、依存しているビューの表示を自動的に更新できます。
ここでは、ビュー階層全体でeventData
プロパティの値を利用するために、.environmentObject
モディファイアを使って最上位階層のビューにeventData`インスタンスを渡します。
Event.swift
このアプリでは、旅行やパーティーなどのイベント行事をEvent
型オブジェクトのコレクションとして整理します。
各イベントには「シンボル、色、タイトル、タスク(やること)のリスト、日付」などの情報が含まれます。
import SwiftUI
struct Event: Identifiable, Hashable {
var id = UUID()
var symbol: String = EventSymbols.randomName()
var color: Color = ColorOptions.random()
var title = ""
var tasks = [EventTask(text: "")]
var date = Date()
var remainingTaskCount: Int {
tasks.filter { !$0.isCompleted }.count
}
var isComplete: Bool {
tasks.allSatisfy { $0.isCompleted }
}
var isPast: Bool {
date < Date.now
}
var isWithinSevenDays: Bool {
!isPast && date < Date.now.sevenDaysOut
}
var isWithinSevenToThirtyDays: Bool {
!isPast && !isWithinSevenDays && date < Date.now.thirtyDaysOut
}
var isDistant: Bool {
date >= Date().thirtyDaysOut
}
static var example = Event(
symbol: "case.fill",
title: "Sayulita Trip",
tasks: [
EventTask(text: "Buy plane tickets"),
EventTask(text: "Get a new bathing suit"),
EventTask(text: "Find an airbnb"),
],
date: Date(timeIntervalSinceNow: 60 * 60 * 24 * 365 * 1.5))
}
// 日付操作を行う便宜上のメソッド
extension Date {
var sevenDaysOut: Date {
Calendar.autoupdatingCurrent.date(byAdding: .day, value: 7, to: self) ?? self
}
var thirtyDaysOut: Date {
Calendar.autoupdatingCurrent.date(byAdding: .day, value: 30, to: self) ?? self
}
}
Event構造体
Event
型をIdentifiable
プロトコルに準拠させることで、イベントのリストを作成する際に各行事が識別できるようになります。
リストの各セクションに表示される項目は、計算プロパティが算出した値に基づいて動的に変化します。
isPast
計算プロパティは「イベントの日付が過去のものかどうか」を算出します。
remainingTaskCount
計算プロパティは、イベントに含まれている「未完了なタスク」の数です。
isComplete
プロパティは、イベントに含まれるすべてのタスクが「完了済みかどうか」の真偽値です。
Calendar構造体
日付の計算や比較のための機能を提供するデータ型です。
Calendar
型は「時間にまつわる計算をどのように行うか」をカプセル化しています。
カレンダーに関する情報を提供したり、与えられたカレンダー単位の範囲を決定したり、与えられた絶対時間に単位を追加するなどの機能を提供します。
autoupdatingCurrent
プロパティ(型プロパティ)
ユーザーの希望するカレンダーの変更を追跡するカレンダーです。
変異した場合、このカレンダーはユーザーの希望するカレンダーを追跡しなくなる。
自動更新されるカレンダーは、他の自動更新されるカレンダーと比較されるだけです。
date(byAdding:value:to:wrappingComponents:)
メソッド
指定された日付に「指定された成分の量」を加算して、計算された新しい日付のDate
型インスタンスを返します。
パラメータ
components
日付に追加する値のセットです。value
指定したコンポーネントに加算する量です。date
開始日です。wrappingComponents
true
の場合、コンポーネントは増分され、オーバーフロー時に0/1にラップアラウンドし、より高次のコンポーネントが増分される原因にならないようにします。デフォルトはfalse
なので、メソッド呼び出し時に省略できますす。
返り値
与えられた入力で日付が計算できなかった場合はnil
を返します。
EventTask.swift
各イベント行事には、「タスク(やること)のリスト」があります。
各タスクは、EventTask
構造体として定義されます。
import Foundation
struct EventTask: Identifiable, Hashable {
var id = UUID()
var text: String
var isCompleted = false
var isNew = false
}
Event
型と同様に、EventTask
型もリスト内で識別できるようにするため、Identifiable
プロトコルに準拠します。
タスクには、「説明文、完了フラグ、新規フラグ」を示す情報があります。
Event
型インスタンスは「タスクの完了フラグ」を数えて、未完了タスクの数を追跡します。
EventData.swift
EventData
型は、DatePlannerアプリのモデルデータとなるオブジェクトであり、すべてのイベントを格納および変更します。
そのために、EventData
型はObservableObject
プロトコルに準拠しています。
また、events
プロパティは@Published
属性でマークされており、この公開値に依存するSwiftUIビューは自動的に表示が更新されます。
import SwiftUI
class EventData: ObservableObject {
@Published var events: [Event] = [
Event(symbol: "gift.fill",
color: .red,
title: "Maya's Birthday",
tasks: [EventTask(text: "Guava kombucha"),
EventTask(text: "Paper cups and plates"),
EventTask(text: "Cheese plate"),
EventTask(text: "Party poppers"),
],
date: Date.roundedHoursFromNow(60 * 60 * 24 * 30)),
Event(symbol: "theatermasks.fill",
color: .yellow,
title: "Pagliacci",
tasks: [EventTask(text: "Buy new tux"),
EventTask(text: "Get tickets"),
EventTask(text: "Pick up Carmen at the airport and bring her to the show"),
],
date: Date.roundedHoursFromNow(60 * 60 * 22)),
Event(symbol: "facemask.fill",
color: .indigo,
title: "Doctor's Appointment",
tasks: [EventTask(text: "Bring medical ID"),
EventTask(text: "Record heart rate data"),
],
date: Date.roundedHoursFromNow(60 * 60 * 24 * 4)),
Event(symbol: "leaf.fill",
color: .green,
title: "Camping Trip",
tasks: [EventTask(text: "Find a sleeping bag"),
EventTask(text: "Bug spray"),
EventTask(text: "Paper towels"),
EventTask(text: "Food for 4 meals"),
EventTask(text: "Straw hat"),
],
date: Date.roundedHoursFromNow(60 * 60 * 36)),
Event(symbol: "gamecontroller.fill",
color: .cyan,
title: "Game Night",
tasks: [EventTask(text: "Find a board game to bring"),
EventTask(text: "Bring a desert to share"),
],
date: Date.roundedHoursFromNow(60 * 60 * 24 * 2)),
Event(symbol: "graduationcap.fill",
color: .primary,
title: "First Day of School",
tasks: [
EventTask(text: "Notebooks"),
EventTask(text: "Pencils"),
EventTask(text: "Binder"),
EventTask(text: "First day of school outfit"),
],
date: Date.roundedHoursFromNow(60 * 60 * 24 * 365)),
Event(symbol: "book.fill",
color: .purple,
title: "Book Launch",
tasks: [
EventTask(text: "Finish first draft"),
EventTask(text: "Send draft to editor"),
EventTask(text: "Final read-through"),
],
date: Date.roundedHoursFromNow(60 * 60 * 24 * 365 * 2)),
Event(symbol: "globe.americas.fill",
color: .gray,
title: "WWDC",
tasks: [
EventTask(text: "Watch Keynote"),
EventTask(text: "Watch What's new in SwiftUI"),
EventTask(text: "Go to DT developer labs"),
EventTask(text: "Learn about Create ML"),
],
date: Date.from(month: 6, day: 7, year: 2021)),
Event(symbol: "case.fill",
color: .orange,
title: "Sayulita Trip",
tasks: [
EventTask(text: "Buy plane tickets"),
EventTask(text: "Get a new bathing suit"),
EventTask(text: "Find a hotel room"),
],
date: Date.roundedHoursFromNow(60 * 60 * 24 * 19)),
]
func delete(_ event: Event) {
events.removeAll { $0.id == event.id }
}
func add(_ event: Event) {
events.append(event)
}
func exists(_ event: Event) -> Bool {
events.contains(event)
}
func sortedEvents(period: Period) -> Binding<[Event]> {
Binding<[Event]>(
get: {
self.events
.filter {
switch period {
case .nextSevenDays:
return $0.isWithinSevenDays
case .nextThirtyDays:
return $0.isWithinSevenToThirtyDays
case .future:
return $0.isDistant
case .past:
return $0.isPast
}
}
.sorted { $0.date < $1.date }
},
set: { events in
for event in events {
if let index = self.events.firstIndex(where: { $0.id == event.id }) {
self.events[index] = event
}
}
}
)
}
}
enum Period: String, CaseIterable, Identifiable {
case nextSevenDays = "Next 7 Days"
case nextThirtyDays = "Next 30 Days"
case future = "Future"
case past = "Past"
var id: String { self.rawValue }
var name: String { self.rawValue }
}
extension Date {
static func from(month: Int, day: Int, year: Int) -> Date {
var dateComponents = DateComponents()
dateComponents.year = year
dateComponents.month = month
dateComponents.day = day
let calendar = Calendar(identifier: .gregorian)
if let date = calendar.date(from: dateComponents) {
return date
} else {
return Date()
}
}
static func roundedHoursFromNow(_ hours: Double) -> Date {
let exactDate = Date(timeIntervalSinceNow: hours)
guard let hourRange = Calendar.current.dateInterval(of: .hour, for: exactDate) else {
return exactDate
}
return hourRange.end
}
}
EventData構造体
events
プロパティは「Event
型インスタンスの配列」の変数です。
この変数はオブザーバブル・オブジェクトの公開値なので、配列の要素を操作すると、それに応じてUIが更新されます。
delete(_:)
メソッド、add(_:)
メソッド、exists(_:)
メソッドは、イベントを追加・削除するための機能を提供します。
また、sortedEvents(period:)
メソッドは、「指定された期間」で並べ替えたイベント配列を返します。(例えば、「今日から7日間分のイベント」など)
Period列挙型
イベントのリストを並べ替えるために使用する「期間のカテゴリ」です。
EventList.swift
イベントをリスト形式で表示するためのビューであり、ナビゲーションの起点になります。
このList
ビューでは、イベントはセクションによってグループごとに分けて表示されます。
import SwiftUI
struct EventList: View {
@EnvironmentObject var eventData: EventData
@State private var isAddingNewEvent = false
@State private var newEvent = Event()
var body: some View {
List {
ForEach(Period.allCases) { period in
if !eventData.sortedEvents(period: period).isEmpty {
Section(content: {
ForEach(eventData.sortedEvents(period: period)) { $event in
NavigationLink {
EventEditor(event: $event)
} label: {
EventRow(event: event)
}
.swipeActions {
Button(role: .destructive) {
eventData.delete(event)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}, header: {
Text(period.name)
.font(.callout)
.foregroundColor(.secondary)
.fontWeight(.bold)
})
}
}
}
.navigationTitle("Date Planner")
.toolbar {
ToolbarItem {
Button {
newEvent = Event()
isAddingNewEvent = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $isAddingNewEvent) {
NavigationView {
EventEditor(event: $newEvent, isNew: true)
}
}
}
}
struct EventList_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
EventList().environmentObject(EventData())
}
}
}
このリストビューはDatePlannerApp
型の定義において、ナビゲーションの最上位ビューとして.environmentObject
モディファイアを使ってEventData
型インスタンスを受け取っています。
したがって、このリストビューではEnvironmentObject
属性のEventData
型プロパティとして変数を宣言することで、「一貫性のあるモデルデータ」にアクセスできるようになります。
このビューのリストでは、ForEach
コンテナに「すべての期間のイベント」を対象に反復処理を実行しています。
そして、「対象の期間」ごとにセクションを設けてイベントを表示します。その期間にイベントが存在しなければ、セクションは作成されません。
リスト内の各イベント行事は、それぞれがEventRow
ビューによって定義されています。
ユーザーがイベントをタップすると、ナビゲーションは画面をEventEditor
ビューに遷移します。
タップによる画面の遷移はNavigationLink
を使用しています。
リストのスワイプ操作によるイベント削除を実装するために、swipeActions
モディファイアを使用しています。
この時、EventData
型のdelete()
メソッドが呼び出されます。
EventRow.swift
import SwiftUI
struct EventRow: View {
let event: Event
var body: some View {
HStack {
Image(systemName: event.symbol)
.sfSymbolStyling()
.foregroundStyle(event.color)
VStack(alignment: .leading, spacing: 5) {
Text(event.title)
.fontWeight(.bold)
Text(event.date.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.secondary)
}
if event.isComplete {
Spacer()
Image(systemName: "checkmark")
.foregroundStyle(.secondary)
}
}
.badge(event.remainingTaskCount)
}
}
struct EventRow_Previews: PreviewProvider {
static var previews: some View {
EventRow(event: Event.example)
}
}
EventDetail.swift
import SwiftUI
struct EventDetail: View {
@Binding var event: Event
let isEditing: Bool
@State private var isPickingSymbol = false
var body: some View {
List {
HStack {
Button {
isPickingSymbol.toggle()
} label: {
Image(systemName: event.symbol)
.sfSymbolStyling()
.foregroundColor(event.color)
.opacity(isEditing ? 0.3 : 1)
}
.buttonStyle(.plain)
.padding(.horizontal, 5)
if isEditing {
TextField("New Event", text: $event.title)
.font(.title2)
} else {
Text(event.title)
.font(.title2)
.fontWeight(.semibold)
}
}
if isEditing {
DatePicker("Date", selection: $event.date)
.labelsHidden()
.listRowSeparator(.hidden)
} else {
HStack {
Text(event.date, style: .date)
Text(event.date, style: .time)
}
.listRowSeparator(.hidden)
}
Text("Tasks")
.fontWeight(.bold)
ForEach($event.tasks) { $item in
TaskRow(task: $item, isEditing: isEditing)
}
.onDelete(perform: { indexSet in
event.tasks.remove(atOffsets: indexSet)
})
Button {
event.tasks.append(EventTask(text: "", isNew: true))
} label: {
HStack {
Image(systemName: "plus")
Text("Add Task")
}
}
.buttonStyle(.borderless)
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.sheet(isPresented: $isPickingSymbol) {
SymbolPicker(event: $event)
}
}
}
struct EventDetail_Previews: PreviewProvider {
static var previews: some View {
EventDetail(event: .constant(Event.example), isEditing: true)
}
}
EventEditor.swift
import SwiftUI
struct EventEditor: View {
@Binding var event: Event
var isNew = false
@State private var isDeleted = false
@EnvironmentObject var eventData: EventData
@Environment(\.dismiss) private var dismiss
// Keep a local copy in case we make edits, so we don't disrupt the list of events.
// This is important for when the date changes and puts the event in a different section.
@State private var eventCopy = Event()
@State private var isEditing = false
private var isEventDeleted: Bool {
!eventData.exists(event) && !isNew
}
var body: some View {
VStack {
EventDetail(event: $eventCopy, isEditing: isNew ? true : isEditing)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if isNew {
Button("Cancel") {
dismiss()
}
}
}
ToolbarItem {
Button {
if isNew {
eventData.events.append(eventCopy)
dismiss()
} else {
if isEditing && !isDeleted {
print("Done, saving any changes to \(event.title).")
withAnimation {
event = eventCopy // Put edits (if any) back in the store.
}
}
isEditing.toggle()
}
} label: {
Text(isNew ? "Add" : (isEditing ? "Done" : "Edit"))
}
}
}
.onAppear {
eventCopy = event // Grab a copy in case we decide to make edits.
}
.disabled(isEventDeleted)
if isEditing && !isNew {
Button(role: .destructive, action: {
isDeleted = true
dismiss()
eventData.delete(event)
}, label: {
Label("Delete Event", systemImage: "trash.circle.fill")
.font(.title2)
.foregroundColor(.red)
})
.padding()
}
}
.overlay(alignment: .center) {
if isEventDeleted {
Color(UIColor.systemBackground)
Text("Event Deleted. Select an Event.")
.foregroundStyle(.secondary)
}
}
}
}
struct EventEditor_Previews: PreviewProvider {
static var previews: some View {
EventEditor(event: .constant(Event()))
}
}
EventSymbols.swift
import Foundation
struct EventSymbols {
static func randomName() -> String {
if let random = symbolNames.randomElement() {
return random
} else {
return ""
}
}
static func randomNames(_ number: Int) -> [String] {
var names: [String] = []
for _ in 0..<number {
names.append(randomName())
}
return names
}
static var symbolNames: [String] = [
"house.fill",
"ticket.fill",
"gamecontroller.fill",
"theatermasks.fill",
"ladybug.fill",
"books.vertical.fill",
"moon.zzz.fill",
"umbrella.fill",
"paintbrush.pointed.fill",
"leaf.fill",
"globe.americas.fill",
"clock.fill",
"building.2.fill",
"gift.fill",
"graduationcap.fill",
"heart.rectangle.fill",
"phone.bubble.left.fill",
"cloud.rain.fill",
"building.columns.fill",
"mic.circle.fill",
"comb.fill",
"person.3.fill",
"bell.fill",
"hammer.fill",
"star.fill",
"crown.fill",
"briefcase.fill",
"speaker.wave.3.fill",
"tshirt.fill",
"tag.fill",
"airplane",
"pawprint.fill",
"case.fill",
"creditcard.fill",
"infinity.circle.fill",
"dice.fill",
"heart.fill",
"camera.fill",
"bicycle",
"radio.fill",
"car.fill",
"flag.fill",
"map.fill",
"figure.wave",
"mappin.and.ellipse",
"facemask.fill",
"eyeglasses",
"tram.fill",
]
}
SFSymbolStyling.swift
import SwiftUI
struct SFSymbolStyling: ViewModifier {
func body(content: Content) -> some View {
content
.imageScale(.large)
.symbolRenderingMode(.monochrome)
}
}
extension View {
func sfSymbolStyling() -> some View {
modifier(SFSymbolStyling())
}
}
SymbolPicker.swift
import SwiftUI
struct SymbolPicker: View {
@Binding var event: Event
@State private var selectedColor: Color = ColorOptions.default
@Environment(\.dismiss) private var dismiss
@State private var symbolNames = EventSymbols.symbolNames
@State private var searchInput = ""
var columns = Array(repeating: GridItem(.flexible()), count: 6)
var body: some View {
VStack {
HStack {
Spacer()
Button {
dismiss()
} label: {
Text("Done")
}
.padding()
}
HStack {
Image(systemName: event.symbol)
.font(.title)
.imageScale(.large)
.foregroundColor(selectedColor)
}
.padding()
HStack {
ForEach(ColorOptions.all, id: \.self) { color in
Button {
selectedColor = color
event.color = color
} label: {
Circle()
.foregroundColor(color)
}
}
}
.padding(.horizontal)
.frame(height: 40)
Divider()
ScrollView {
LazyVGrid(columns: columns) {
ForEach(symbolNames, id: \.self) { symbolItem in
Button {
event.symbol = symbolItem
} label: {
Image(systemName: symbolItem)
.sfSymbolStyling()
.foregroundColor(selectedColor)
.padding(5)
}
.buttonStyle(.plain)
}
}
.drawingGroup()
}
}
.onAppear {
selectedColor = event.color
}
}
}
struct SFSymbolBrowser_Previews: PreviewProvider {
static var previews: some View {
SymbolPicker(event: .constant(Event.example))
}
}
TaskRow.swift
import SwiftUI
struct TaskRow: View {
@Binding var task: EventTask
var isEditing: Bool
@FocusState private var isFocused: Bool
var body: some View {
HStack {
Button {
task.isCompleted.toggle()
} label: {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
}
.buttonStyle(.plain)
if isEditing || task.isNew {
TextField("Task description", text: $task.text)
.focused($isFocused)
.onChange(of: isFocused) { newValue in
if newValue == false {
task.isNew = false
}
}
} else {
Text(task.text)
}
Spacer()
}
.padding(.vertical, 10)
.task {
if task.isNew {
isFocused = true
}
}
}
}
struct TaskRow_Previews: PreviewProvider {
static var previews: some View {
TaskRow(task: .constant(EventTask(text: "Do something!")), isEditing: false)
}
}
ColorOption.swift
import SwiftUI
struct ColorOptions {
static var all: [Color] = [
.primary,
.gray,
.red,
.orange,
.yellow,
.green,
.mint,
.cyan,
.indigo,
.purple,
]
static var `default` : Color = Color.primary
static func random() -> Color {
if let element = ColorOptions.all.randomElement() {
return element
} else {
return .primary
}
}
}