こんにちは、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()
}
}
Question1のPreview結果
解説
この問題は、右上・右下・左上・左下・中央に配置されている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")
}
}
Question2のPreview結果
解説
この問題では、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)
}
}
}
Question3のPreview結果
解説
この問題は、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)
}
}
Question4のPreview結果
解説
この問題では、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()
}
Question5のPreview結果
解説
この問題では、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()
}
}
Question6のPreview結果
解説
この問題は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()
}
}
Question7のPreview結果
解説
この問題は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
)
}
}
Question8のPreview結果
解説
この問題は、円環上に配置された数字を正確にプレビューする問題です。 円周上の数字の配置や回転に関する高校数学の知識が試されています。
キーポイント
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)
}
}
Question9のPreview結果
解説
コードから以下の情報を読み解くことができます。
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
}
}
}
Question10のPreview結果
解説
この問題は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")
}
}
}
}
Question11のPreview結果
解説
このコードは、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()
}
}
Question12のPreview結果
解説
このコードでは、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の問題紹介&解説になります! 多くの方々に体験を提供できたことを嬉しく思っています。今後も色々なイベントに対して貢献していきたいと思っています。 最後まで読んでいただき、本当にありがとうございました!!
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。