こんにちは、23卒の ojun です!
2024年3月22日(金)から24日(日)までの期間で開催された try! Swift Tokyo 2024 にて、DeNA はゴールドスポンサーとして協賛しました! さらに、DeNA は try! Swift Tokyo 2024 の「スポンサーブース」に出展し、iOSアプリエンジニアたちが考えた企画である「SwiftUI Preview Challenge」を実施しました!!
当日はたくさんの方々にご参加いただき、大盛況となりました。Challengeに参加してくださった皆様ありがとうございました。 また、「SwiftUI Preview Challenge」で集めた解答用紙は、362枚にものぼりました!解答用紙の持ち帰りも可能であったので、実際はより多くの方に参加してもらえました このブログでは、「SwiftUI Preview Challenge」の問題とその答え、さらに解説を行います。
Question1のコードと解説
Question1の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question1View: View {
private let helloString = "Hello, try! Swift Tokyo 2024!"
var body: some View {
VStack {
HStack {
Text(helloString)
.layoutPriority(1)
Spacer()
Text(helloString)
}
Spacer()
Text(helloString)
Spacer()
HStack {
Text(helloString)
Spacer()
Text(helloString)
.layoutPriority(1)
}
}
.padding()
}
}
解説
この問題は、右上・右下・左上・左下・中央に配置されているHello, try! Swift Tokyo 2024!
をプレビューする問題です。
キーポイント
この問題を解くためのポイントは、
layoutPriority
にあります。
このメソッドを利用し値を設定することで、親レイアウトが子レイアウトにスペースを割り当てる優先順位を設定します。デフォルトは0
です。
問題では2つのText
に対して.layoutPriority(1)
が割り当てられており、これはデフォルト値である0
よりも大きいので、改行がされなくなります。
Question2のコードと解説
Question2の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question2View: View {
@State private var isOn = false
@State private var sliderValue: Double = 75
var body: some View {
Form {
Section("try! Swift Tokyo 2024") {
Toggle(isOn: $isOn) {
Text("Toggle")
}
Slider(value: $sliderValue, in: 0...100) {
Text("Slider")
}
GroupBox("try! Swift") {
Text("try! Swift is global tech conference of Swift!")
}
}
}
.navigationBarTitle("DeNA Booth")
}
}
解説
この問題では、Form
とSection
を中心に構築された標準的なUIコンポーネントのプレビューがテーマとなっています。
多くの設定画面などで見かける典型的なUIです。
Slider
のイニシャライザにおけるlabel
の引数にText("Slider")
という値が与えられていますが、これが画面上には表示されない点がトリッキーな部分です。
キーポイント
Slider
のイニシャライザ、
init(value:in:label:onEditingChanged:)
におけるlabel
は、その機能や目的を説明するためのビューとして提供されます。 このlabelが必ずしも画面上に表示されるわけではない点を理解することが問題を解く鍵になります。実際、指定したlabel
は表示されず、VoiceOverのようなアクセシビリティ機能の際に活用されます。
Question3のコードと解説
Question3の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question3View: View {
var body: some View {
VStack(spacing: 16) {
Button("Plain") {}
.buttonStyle(.plain)
Button("Bordered") {}
.buttonStyle(.bordered)
Button("Bordered Prominent") {}
.buttonStyle(.borderedProminent)
Button("Borderless") {}
.buttonStyle(.borderless)
Button(action: {}) {
Label("Search", systemImage: "magnifyingglass")
.padding()
.background(.blue)
.foregroundStyle(.white)
}
.cornerRadius(16)
}
}
}
解説
この問題は、buttonStyle
を利用することでButton
の見た目がどのように変化するかを推測する問題でした。
.buttonStyle(.bordered)
を指定した場合はボタンに対して枠線が付与されるのではなく、灰色の背景が付与される見た目になることに注意が必要です。
キーポイント
.plain
は待機中の状態だと一切装飾がされません。タップするとハイライトされますが、ライトモードかつ文字サイズが小さいとほとんどわかりません。.bordered
は角丸四角形です。.borderedProminent
は「Prominent(目立つ)」の通り、.bordered
より目立つ装飾です。.borderless
は境界のないスタイルで、テキストリンクによく使われます。ボタンのデフォルトである.automatic
もデフォルトで.borderless
となります。
また、以下のボタンではLabel("Search", systemImage: "magnifyingglass")
が設定されています。コード上ではSearch
・magnifyingglass
の順番ですが、レンダリングされる順番はmagnifyingglass
・Search
になることを理解することがこの問題を正解させるキーポイントになります。
Button(action: {}) {
Label("Search", systemImage: "magnifyingglass")
.padding()
.background(.blue)
.foregroundStyle(.white)
}
Question4のコードと解説
Question4の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question4View: View {
var body: some View {
HStack {
Color.gray
.frame(width: 100, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay {
Text("DeNA")
.bold()
}
Spacer()
Ellipse()
.fill(.gray)
.frame(width: 200, height: 100)
.border(.black)
.overlay {
Text("Booth")
.bold()
}
}
.padding(.horizontal, 24)
}
}
解説
この問題では、Color
と図形であるEllipse()
を用いたプレビューが課題となっています。
これらのコンポーネントの間にSpacer
が挿入されており、その結果、図形が均等な間隔で配置されるような表示になっています。
キーポイント
グレー色で描かれた形が両者で異なっていますが、前者はColor.gray
をRoundedRectangle
(角丸四角形)でclipShape(_:style:)
したもの、後者はEllipse
(楕円)をColor.gray
でfill(_:style:)
したものになります。
Color
の部分に関しては、.overlay
を利用しDeNA
というテキストを表示させている部分がポイントになります。
また、Ellipse()
の部分に関しては、.border(.black)
を適応させています。.border(.black)
を適応さ焦ると、楕円の周りでなくframe
の周りに枠線を引くためこのような表示になります。
Question5のコードと解説
Question5の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question5View: View {
private let symbols = [
"swift", "apple.terminal", "applescript",
"accessibility", "hare", "tortoise",
"opticid", "skateboard", "fireworks",
]
private let columns: [GridItem] = [
.init(.flexible(), spacing: 16),
.init(.flexible(), spacing: 16),
.init(.flexible()),
]
var body: some View {
VStack {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(symbols, id: \.self) { symbol in
Image(systemName: symbol)
.font(.system(size: 48))
}
}
}
.padding()
}
解説
この問題では、Appleが提供する9種類のSF Symbols
を正確にプレビューする能力が求められます。これにより、ユーザーのApple製品やSF Symbols
への知識と愛情が試されます。
野うさぎが描画されるhare
リクガメが描画されるtortoise
は、たとえば iOS のアクセシビリティ設定の「読み上げ速度」設定項目において、シンボルとして使われています。
ちなみにopticid
・skateboard
・fireworks
の3つは、iOS 17・macOS Sonoma などから導入された、比較的新しいシンボルです。
Question6のコードと解説
Question6の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question6View: View {
private let symbols = [
"chevron.forward", "square.and.arrow.up", "ellipsis",
"delete.forward", "book", "bookmark",
"video", "timelapse", "scribble",
]
private let columns: [GridItem] = [
.init(.flexible(), spacing: 16),
.init(.flexible(), spacing: 16),
.init(.flexible()),
]
var body: some View {
VStack {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(symbols, id: \.self) { symbol in
Image(systemName: symbol)
.font(.system(size: 48))
}
}
}
.padding()
}
}
解説
この問題はQuestion5
と同様にSF Symbols
をプレビューする問題です。
chevron.forward
・delete.forward
は、端末に設定されている言語によって矢印の向きが異なります。
今回のスクリーンショットは日本語環境(ja_JP
)で左横書き言語のため、このように描画されています。
Question7のコードと解説
Question7の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question7View: View {
private let symbols = [
"iphone.gen1", "iphone.gen2", "iphone.gen3",
"macpro.gen1", "macpro.gen2", "macpro.gen3",
]
private let columns: [GridItem] = [
.init(.flexible(), spacing: 16),
.init(.flexible(), spacing: 16),
.init(.flexible()),
]
var body: some View {
VStack {
LazyVGrid(columns: columns, spacing: 32) {
ForEach(symbols, id: \.self) { symbol in
Image(systemName: symbol)
.font(.system(size: 96))
}
}
}
.padding()
}
}
解説
この問題はQuestion5
・Question6
と同様にSF Symbols
をプレビューする問題です。
iphone.gen1
はホームボタン付きのiPhone
を、iphone.gen2
は全画面ディスプレイモデルのiPhoneを、iphone.gen3
は全画面ディスプレイモデルかつDynamic Islandを搭載しているiPhoneです。
また、macpro.gen1
は2013年まで、macpro.gen2
は2013年〜2019年、macpro.gen3
は2019年以降のモデルの外観になっています。
Question8のコードと解説
Question8の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question8View: View {
private let indexes = [0, 1, 2, 3, 4, 5]
var body: some View {
ZStack {
ForEach(0..<indexes.count, id: \.self) { index in
RadialItem(
text: "\(index)",
angle: (2 * .pi / Double(indexes.count)) * Double(index)
)
}
}
}
}
private struct RadialItem: View {
let text: String
let angle: Double
private let offsetValue: CGFloat = 110
var body: some View {
Circle()
.overlay {
Text(text)
.font(.title)
.foregroundStyle(.white)
}
.frame(width: 60, height: 60)
.offset(
x: cos(CGFloat(angle)) * offsetValue,
y: -sin(CGFloat(angle)) * offsetValue
)
}
}
解説
この問題は、円環上に配置された数字を正確にプレビューする問題です。 円周上の数字の配置や回転に関する高校数学の知識が試されています。
キーポイント
offset
のy
に対して-sin(CGFloat(angle)) * offsetValue
が与えられています。-
が掛けられていることにより表示順が反時計回りになることがキーポイントです。
また最初に表示される0
が画面の右側から始まることも問題を解く鍵になります。数学的にX軸の正の方向と一致しており、そこから上記で説明した通り反時計回りに数字が表示されるという挙動が重要になります。
(このスクリーンショットの環境(iOS・SwiftUI(・UIKit))では、端末画面左上が原点となっています。)
Question9のコードと解説
Question9の問題とPreview結果は以下のとおりです。
import SwiftUI
import Charts
struct Question9View: View {
private enum BuildConfiguration: String {
case debug, release
}
private struct SourceFileBuildMetrics: Identifiable {
let id = UUID()
let moduleName: String
let compileTime: Double
let configuration: BuildConfiguration
}
private let buildMetrics: [SourceFileBuildMetrics] = [
.init(moduleName: "Core", compileTime: 0.1, configuration: .debug),
.init(moduleName: "Data", compileTime: 0.2, configuration: .debug),
.init(moduleName: "Core", compileTime: 0.3, configuration: .release),
.init(moduleName: "Data", compileTime: 0.4, configuration: .release),
]
var body: some View {
Chart(buildMetrics) { metric in
BarMark(
x: .value("Name", metric.moduleName),
y: .value("Time", metric.compileTime)
)
.position(
by: .value(
"Configuration",
metric.configuration.rawValue
)
)
}
.padding(32)
}
}
解説
コードから以下の情報を読み解くことができます。
Core
モジュールがdebug
ビルドで0.1s
かかることData
モジュールがdebug
ビルドで0.2s
かかることCore
モジュールがrelease
ビルドで0.3s
かかることData
モジュールがrelease
ビルドで0.4s
かかること
Chart
ビューは、これらのメトリクスを利用して、モジュールごとのコンパイル時間を棒グラフで表示します。
棒グラフのX軸はモジュール名を、Y軸はコンパイル時間を表し、.position
モディファイアを通じてビルド設定によるグルーピングが行われています。
キーポイント
SourceFileBuildMetrics
構造体は、各ソースファイルのビルドメトリクスを表します。buildMetrics
配列は、異なるモジュールとビルド設定のコンパイル時間を保持します。Chart
ビューを使用して、モジュール名に基づいてコンパイル時間を棒グラフで表示します。.position
モディファイアにより、ビルド設定(デバッグ、リリース)に基づいてデータがグルーピングされます。
Question10のコードと解説
Question10の問題とPreview結果は以下のとおりです。
import SwiftUI
import Charts
struct Question10View: View {
@State private var selectedHourIndex = 6
private let postModels: [WeatherInfo] = [
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 0)!, temperature: 6, humidity: 60),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 3)!, temperature: 12, humidity: 50),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 6)!, temperature: 18, humidity: 40),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 9)!, temperature: 24, humidity: 30),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 12)!, temperature: 30, humidity: 20),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 15)!, temperature: 36, humidity: 10),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 18)!, temperature: 30, humidity: 20),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 21)!, temperature: 24, humidity: 30),
WeatherInfo(date: Date(year: 2024, month: 3, day: 22, hour: 24)!, temperature: 10, humidity: 40),
]
private var selectedDate: Date? {
if postModels.isEmpty { return nil }
return postModels[min(selectedHourIndex, postModels.count - 1)].date
}
private var dataList: [GraphDataModel] {
postModels.map({ GraphDataModel(type: .humidity, xValue: $0.date, yValue: $0.humidity) })
+ postModels.map({ GraphDataModel(type: .temperature, xValue: $0.date, yValue: $0.temperature) })
}
var body: some View {
Chart(dataList) { data in
LineMark(
x: .value("hour", data.xValue, unit: .hour),
y: .value("value", data.yValue)
)
.foregroundStyle(by: .value("Data Type", data.type.rawValue))
.interpolationMethod(.cardinal)
if let selectedDate {
RuleMark(x: .value("Selected date", selectedDate, unit: .hour))
.foregroundStyle(Color.red)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .hour, count: 3, roundLowerBound: false)) {
AxisGridLine()
AxisValueLabel(format: .dateTime.hour(), anchor: .top)
}
}
.chartYAxis {
let temperatureValues = [10, 20, 30, 40, 50]
AxisMarks(
position: .leading,
values: temperatureValues
) {
AxisGridLine()
AxisValueLabel("\(temperatureValues[$0.index])℃")
}
let humidityValues = [10, 20, 30, 40, 50, 60]
AxisMarks(
format: .percent,
position: .trailing,
values: humidityValues,
stroke: .init(dash: [8, 2])
)
}
.frame(height: 300)
.padding()
}
}
// MARK: - Privates
private struct WeatherInfo: Identifiable {
let id = UUID().uuidString
let date: Date
let temperature: Int
let humidity: Int
}
private enum WeatherDataType: String {
case humidity
case temperature
}
private struct GraphDataModel: Identifiable {
var id: String { type.rawValue + xValue.description + yValue.description }
let type: WeatherDataType
let xValue: Date
let yValue: Int
}
private extension Date {
init?(year: Int, month: Int, day: Int, hour: Int = 0, minute: Int = 0, second: Int = 0, calendar: Calendar = Calendar.current, timeZone: TimeZone = TimeZone.current) {
var dateComponents = DateComponents()
dateComponents.year = year
dateComponents.month = month
dateComponents.day = day
dateComponents.hour = hour
dateComponents.minute = minute
dateComponents.second = second
dateComponents.timeZone = timeZone
if let date = calendar.date(from: dateComponents) {
self = date
} else {
return nil
}
}
}
解説
この問題はSwiftUIとChartsフレームワークを使って、特定の日における時間ごとの気温と湿度を折れ線グラフで表示する問題です。
WeatherInfo
構造体は各時点の気象情報を保持し、これらのデータはGraphDataModel
を通じてグラフに描画されます。
LineMark
を使用して、気温と湿度のデータを折れ線グラフで表示しています。これにはforegroundStyle
でデータタイプ(気温または湿度)に基づいた色分けが行われ、interpolationMethod(.cardinal)
でグラフの線が滑らかになります。chartXAxis
によりX軸(時間)が設定されており、AxisMarks
を用いて3時間ごとに目盛りが配置されます。- Y軸には2つのスケールが設定されています:左側は気温を、右側は湿度を表しています。これらは
AxisMarks
で定義され、異なるY軸に値が配置されます。 RuleMark
を用いて、選択された時間(ここではselectedHourIndex
に基づく時刻)に対応するY軸に平行なマーカー線が描かれます。
キーポイント
WeatherInfo
とGraphDataModel
を使用して、気象データを構造化し、グラフ描画に適した形に変換されますLineMark
を用いて気温と湿度のデータを折れ線グラフで表示し、foregroundStyle
でデータタイプに応じた色分けが行われます- X軸とY軸のカスタマイズ方法を理解し、特にY軸において2つの異なるスケール(気温と湿度)をどのように表現するかが重要です。
- 特定の時点を強調するために
RuleMark
を使用し、選択された時間をグラフ上にマークします。
Question11のコードと解説
Question11の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question11View: View {
var body: some View {
Text("ToolBar")
.toolbar {
ToolbarItemGroup(placement: .navigation) {
Text("A")
}
ToolbarItemGroup(placement: .bottomBar) {
Text("B")
Text("C")
Text("D")
}
ToolbarItem(placement: .confirmationAction) {
Text("E")
Text("F")
}
ToolbarItem(placement: .destructiveAction) {
Text("G")
Text("H")
}
ToolbarItem(placement: .primaryAction) {
Text("I")
Text("J")
}
ToolbarItem(placement: .principal) {
Text("K")
Text("L")
}
ToolbarItem(placement: .status) {
Text("M")
}
ToolbarItem(placement: .secondaryAction) {
Text("N")
}
ToolbarItem(placement: .cancellationAction) {
Text("O")
}
}
}
}
解説
このコードは、SwiftUIのtoolbar
を使用して、異なるToolbarItem
とToolbarItemGroup
を配置する方法を問う問題でした。
それぞれのToolbarItem
およびToolbarItemGroup
は、特定のplacement
に配置され、そのplacement
はツールバー内のアイテムの位置を決定します。
.navigation
はナビゲーションバーに表示されます。.bottomBar
は画面下部のバーに表示されます。.confirmationAction
、.destructiveAction
、.primaryAction
、.secondaryAction
、.cancellationAction
はアクションの性質に応じて適切な場所に配置されます。.principal
は主要なビューとして、通常はナビゲーションバーの中央に表示されます。.status
はステータス表示用で、位置はプラットフォームによって異なる場合があります。ToolbarItemGroup
を使用して、複数のアイテムを同じ位置にグループ化して配置できます。ToolbarItem
内で複数のビューを定義しても、通常は最初のビューのみが表示されます。
キーポイント
ToolbarItemPlacement
によってツールバーアイテムの位置が決定されます。詳しくは 公式ドキュメント を確認ください。ToolbarItemGroup
を使用して複数のアイテムを1つのグループとして配置できます。- 各配置タイプは特定の文脈やプラットフォームに応じて異なる位置にアイテムを表示することを意図しています。例えば、
.navigation
はナビゲーションバーに、.bottomBar
は画面の下部にアイテムを配置します。 - 複数のビューを含む
ToolbarItem
では、通常、最初のビューのみが表示されることを理解することが重要です。複数のビューを表示したい場合はToolbarItemGroup
を使用します。
解説の具体的な内容については、使用しているプラットフォーム(iOS, macOSなど)によって挙動が異なる可能性があるため、実際の挙動を確認しながら理解を深めることが重要です。
Question12のコードと解説
Question12の問題とPreview結果は以下のとおりです。
import SwiftUI
struct Question12View: View {
private let minValue: Double = 0
private let maxValue: Double = 100
@State private var current: Double = 60
private var gaugeView: some View {
Gauge(value: current, in: minValue...maxValue) {
Image(systemName: "heart")
.foregroundColor(.red)
} currentValueLabel: {
Text("\(current)")
.foregroundStyle(.green)
} minimumValueLabel: {
Text(String(Int(minValue)))
.foregroundStyle(.green)
} maximumValueLabel: {
Text(String(Int(maxValue)))
.foregroundStyle(.red)
}
}
var body: some View {
VStack(spacing: 40) {
gaugeView
.gaugeStyle(.accessoryCircular)
gaugeView
.gaugeStyle(.linearCapacity)
Spacer()
}
.padding()
}
}
解説
このコードでは、SwiftUIのGauge
ビューを使用して、現在値を視覚的に示すゲージを作成しています。
Gauge
はiOS 16で導入され、様々なスタイルでカスタマイズ可能なコンポーネントです。主にロック画面で使用されるため小さく表示されることが特徴です。
この例では、2種類のゲージスタイル(.accessoryCircular
と.linearCapacity
)を示しており、それぞれに最小値、最大値、現在値のラベルが付いています。
.accessoryCircular
スタイルは円形のゲージを生成し、一般的には小さいサイズで表示されます。.linearCapacity
スタイルは線形のゲージを生成し、進捗表示などに適しています。
ゲージには最小値と最大値のラベルが付いており、現在値は緑色で表示されます。最小値のラベルは緑色、最大値のラベルは赤色で表示されています。
キーポイント
Gauge
ビューは、数値の現在値を視覚的に表現するために使用されます。- 1つ目のクロージャー内のビューは、Question2 の
Slider
と同様に表示されません。 .accessoryCircular
と.linearCapacity
はGauge
の異なるスタイルを表し、それぞれが異なる視覚表現を提供します。Gauge
の各部分(現在値ラベル、最小値ラベル、最大値ラベル)はカスタマイズ可能で、色やテキストの形式を変更できます。- 数値の表示形式は、整数に変換するか、または
Double
として表示するかによって異なります。整数に変換すると小数点以下は切り捨てられ、Double
のまま表示すると小数点以下も表示されます。
この解説では、Gauge
の使用方法とカスタマイズ可能な要素に焦点を当てています。これらのポイントは、Gauge
の機能と柔軟性を理解するのに役立ちます。
以上が、try! Swift Tokyo 2024のDeNAブースで開催したSwiftUI Preview Challengeの問題紹介&解説になります! 多くの方々に体験を提供できたことを嬉しく思っています。今後も色々なイベントに対して貢献していきたいと思っています。 最後まで読んでいただき、本当にありがとうございました!!
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。