iPhoneアプリ開発メモ - 棒グラフの作成(UIKit/CoreGraphics) (2)
目標
- 前回の棒グラフを複数ページにする
- 今回は、1ページに最大5本の棒が並ぶ、とする。
- ページは横スライドで切り替える
プロジェクトは前回のものを引き継がず、新しく作る。
用いるデータ
ViewController.swift
が次のようなデータを持っていることを想定する。
var dataSource = [
(7, "太郎"), (1, "次郎"), (2, "三郎"), (6, "四郎"), (3, "五郎"),
(9, "六郎"), (2, "七郎"), (3, "八郎"), (1, "九郎"), (5, "十郎"),
(1, "十一郎"), (1, "十二郎"), (6, "十三郎")
]
このデータは、後で整形してグラフ描画用のデータに変換する。
UScrollViewの配置
Main.storyboard
にUIScrollViewを配置する。サイズ設定はコードで行うので、ここでは単に配置するだけ。
その後、UIScrollViewのOutlet接続をViewController.swift
に対して行う。
UIScrollVieの設定
「スクロールの対象となるコンテンツを中に入れる」だけで、スクロール可能なViewが作れる。ただし、思い通りの表示にするためには、UIScrollViewやコンテンツのサイズを設定しておく必要がある。
複数ページを持つコンテンツを作りたいので、コンテンツ用Viewの中にページ用のViewが複数存在する状態になる。なのでページの位置やサイズもちゃんと設定する。
ScrollViewのレイアウト
こんな感じにする。
なのでコードはこんな感じにする。
class ViewController: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
let marginX: CGFloat = 80
let marginY: CGFloat = 40
override func viewDidLoad() {
super.viewDidLoad()
scrollView.frame = CGRect(
x: marginX,
y: marginY,
width: view.bounds.width - 2*marginX,
height: view.bounds.height - 2*marginY
)
}
}
contentsViewのレイアウト
例えばページが5枚だとすると、ページ5枚で1つのcontentsViewを作る。それを念頭においてレイアウトを組む必要がある。
ダミー用GraphView作成
GraphViewに関する位置、サイズの設定は、イニシャライザに任せるように書く。引数として、(ページ番号, スクロールビューのサイズ, データ)
を与える。とりあえず今の所はデータを色として、これをbackgroundColor
に設定する。
上のレイアウトから、i番目のページのx座標はi * size.width
で与えられることが分かる。
class GraphView: UIView {
init(index: Int, size: CGSize, color: UIColor) {
super.init(frame: CGRect.zero)
backgroundColor = color
frame = CGRect(
x: CGFloat(index) * size.width,
y: 0,
width: size.width,
height: size.height
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
}
}
VieController.swift
ページ数とデータを定数として定義する。contentsViewを作る処理は関数に分けておく。
let data: [UIColor] = [.systemRed, .systemIndigo, .systemPink, .systemGreen]
lazy var numOfPages: Int = data.count
@IBOutlet weak var scrollView: UIScrollView!
...
let contentsView = createContentsView()
scrollView.addSubview(contentsView)
scrollView.contentSize = contentsView.frame.size
}
private func createContentsView() -> UIView {
let contentsView = UIView(frame: CGRect(
x: 0,
y: 0,
width: scrollView.frame.width * CGFloat(numOfPages),
height: scrollView.frame.height
))
for (i, datum) in data.enumerated() {
let pageView = GraphView(index: i, size: scrollView.frame.size, color: datum)
contentsView.addSubview(pageView)
}
return contentsView
}
この時点でアプリを起動してみると、次のようになる。よく見ると下にスクロールバーが付いている。
ユーティリティ関数/メソッドの定義
Util.swift
を作成して、以下の記述を追加する。これは前回お世話になったscaleLinear関数。
import UIKit
// ran[1] - ran[0]
// f(x) = ----------------- * (x - dom[0]) + ran[0]
// dom[1] - dom[0]
func scaleLinear(domain dom : (CGFloat, CGFloat), range ran: (CGFloat, CGFloat)) -> ((CGFloat) -> CGFloat) {
return { (x: CGFloat) -> CGFloat in
(ran.1 - ran.0)/(dom.1 - dom.0)*(x - dom.0)+ran.0;
}
}
続いて、以下の記述を追加する。これはArray
のextensionで、ある配列をk個単位に分割するときに用いる。例えば、[1,2,3,4,5,6,7,8].chunked(into: 3)
とすると、[[1,2,3],[4,5,6],[7.8]]
が返ってくる。
ちなみにこのメソッド定義はこちらのもの。stride
を使ってうまく書けている。
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0 ..< Swift.min($0 + size, count)])
}
}
}
棒グラフのレイアウト
前回と少し変えて次のようにする。
Barの番号は0から数えるものとする。なのでx座標用のscaleLinearを定義するときにdomainを(0, numOfBars-1)
にする。前回はこの辺りの記述をあまり考えずやっていたので、x座標が若干ずれていたかもしれない。
padXは画面両端の余白と定義する。barWidthは固定長として定義し、Barの間隔は前回のxScale
によって勝手に設定されるので考えない。
グラフ描画情報を持つクラスの作成
グラフの大きさやscale関数、テキストの大きさやフォントなどの設定はクラスとしてまとめておく。
新たにBarChart.swift
を作成し、内容を以下のようにする。
struct BarChartScale {
var x: (CGFloat) -> CGFloat
var y: (CGFloat) -> CGFloat
var h: (CGFloat) -> CGFloat
}
struct BarChartTextOption {
var font: UIFont
var textStyle: NSMutableParagraphStyle
var foregroundColor: UIColor
var textFontAttributes: [NSAttributedString.Key : NSObject] {
let textFontAttributes = [NSAttributedString.Key.font: font,
NSAttributedString.Key.paragraphStyle: textStyle,
NSAttributedString.Key.foregroundColor: foregroundColor]
return textFontAttributes
}
var textWidth: CGFloat
var textHeight: CGFloat
}
struct BarChartConfig {
var barsArea: CGRect
var numOfBars: Int
var barWidth: CGFloat
var textOption: BarChartTextOption
var scale: BarChartScale
}
グラフのレイアウト設定
先ほど作ったBarChartConfig
をインスタンス化して、グラフ描画の情報を設定する。
ViewController
の先頭に以下の記述を追加する。
lazy var barChartConf = configureBarChart()
さらにクラス内に以下のメソッドを追加する。
private func configureBarChart() -> BarChartConfig {
let numOfBars = 5
let rect = scrollView.frame
let barWidth: CGFloat = 40
let padX: CGFloat = 20
let padY: CGFloat = rect.height / 4
let textAreaHeight: CGFloat = 70
let barsArea = CGRect(
x: padX + barWidth/2,
y: padY,
width: rect.width - 2*(padX + barWidth/2),
height: rect.height - (padY + textAreaHeight)
)
let maxVal = dataSource.max(by: { $0.0 < $1.0 })?.0 ?? -1
let scale = BarChartScale(
x: scaleLinear(domain: (0, CGFloat(numOfBars-1)), range: (barsArea.minX, barsArea.maxX)),
y: scaleLinear(domain: (0, CGFloat(maxVal)), range: (barsArea.maxY, barsArea.minY)),
h: scaleLinear(domain: (0, CGFloat(maxVal)), range: (0, barsArea.height))
)
let textStyle = NSMutableParagraphStyle()
textStyle.alignment = .center
let textOption = BarChartTextOption(
font: .systemFont(ofSize: 20),
textStyle: textStyle,
foregroundColor: .gray,
textWidth: 90,
textHeight: 30
)
return BarChartConfig(
barsArea: barsArea,
numOfBars: numOfBars,
barWidth: barWidth,
textOption: textOption,
scale: scale
)
}
データの整形
BarChartModel
棒グラフに必要なデータを定義したモデルを定義する。前回は最大値の棒だけ色をオレンジにしていたが、今回はそれをもっと抽象化し、色をモデルに含めることにする。
struct BarChartModel {
var value: CGFloat
var name: String
var color: UIColor
init(_ src: (Int, String), color: UIColor) {
self.value = CGFloat(src.0)
self.name = src.1
self.color = color
}
}
dataの変更とdataSourceの定義
先ほど定義していたdata: [UIColor]
は消す。代わりに以下のようにする。data
は二次元配列であることに注意。
var dataSource = [
(7, "太郎"), (1, "次郎"), (2, "三郎"), (6, "四郎"), (3, "五郎"),
(9, "六郎"), (2, "七郎"), (3, "八郎"), (1, "九郎"), (5, "十郎"),
(1, "十一郎"), (1, "十二郎"), (6, "十三郎")
]
lazy var data: [[BarChartModel]] = format(dataSource)
dataSourceの整形
次のメソッドを追加する。これはdataSource
をBarChartModel
の形式に変換し、numOfBars
個ずつに区切った配列を返す。
private func format(_ data: [(Int, String)]) -> [[BarChartModel]] {
guard let maxDatum = (data.max { $0.0 < $1.0 }) else {
print("Failed to get max.")
return []
}
let barChartData: [BarChartModel] = data.map {
if $0.0 == maxDatum.0 {
return BarChartModel($0, color: .systemOrange)
} else {
return BarChartModel($0, color: .systemBlue)
}
}
return barChartData.chunked(into: barChartConf.numOfBars)
}
GraphView.swiftの修正
init
の引数を修正し、backgroundColor
は.white
で固定にする。
init(index: Int, size: CGSize, barChartConf: BarChartConfig, barItems: [BarChartModel]) {
super.init(frame: CGRect.zero)
backgroundColor = .white
frame = CGRect(
x: CGFloat(index) * size.width,
y: 0,
width: size.width,
height: size.height
)
}
これに合わせて、ViewController.swift
において、createContentsView
関数内のGraphView
のインスタンス化部分を以下のように修正する。
for (i, datum) in data.enumerated() {
let pageView = GraphView(index: i, size: scrollView.frame.size, barChartConf: barChartConf, barItems: datum)
contentsView.addSubview(pageView)
}
これでエラーは出なくなるが、まだ何も描いてない。
グラフ描画
ここまでくるとあとは前回と同じなので、さっと書く。GraphView.swift
に以下の記述を追加する。前回とは違って、いくつかの処理をメソッドに分離している。
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else {
print("Failed to get context.")
return
}
for (i, item) in barItems.enumerated() {
drawBar(with: context, of: item, index: i)
drawValue(with: context, of: item, index: i)
drawName(with: context, of: item, index: i)
}
drawAxis(with: context)
}
func drawBar(with context: CGContext, of item: BarChartModel, index i: Int) {
let bcc = barChartConf
context.setFillColor(item.color.cgColor)
let drawRect = UIBezierPath(rect: CGRect(
x: bcc.scale.x(CGFloat(i)) - bcc.barWidth/2,
y: bcc.scale.y(CGFloat(item.value)),
width: bcc.barWidth,
height: bcc.scale.h(CGFloat(item.value))
))
drawRect.fill()
}
private func drawValue(with context: CGContext, of item: BarChartModel, index i: Int) {
let bcc = barChartConf
let to = bcc.textOption
let value = Int(item.value)
let valueTextRect = CGRect(
x: bcc.scale.x(CGFloat(i)) - to.textWidth/2,
y: bcc.scale.y(item.value) - to.textHeight,
width: to.textWidth,
height: to.textHeight
)
let titleText = "\(value)"
titleText.draw(in: valueTextRect, withAttributes: to.textFontAttributes)
}
private func drawName(with context: CGContext, of item: BarChartModel, index i: Int) {
let bcc = barChartConf
let to = bcc.textOption
let nameRect = CGRect(
x: bcc.scale.x(CGFloat(i)) - to.textWidth/2,
y: bcc.scale.y(0) + to.textHeight/2,
width: to.textWidth,
height: to.textHeight
)
item.name.draw(in: nameRect, withAttributes: to.textFontAttributes)
}
private func drawAxis(with context: CGContext) {
context.setStrokeColor(UIColor.black.cgColor)
let scale = barChartConf.scale
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: scale.y(0)))
path.addLine(to: CGPoint(x: frame.width, y: scale.y(0)))
path.stroke()
}
大変だったがなんとか完成。
追記: ScrollViewのレイアウト修正
実はいまのままだと、例えばtoolbarが出たときにレイアウトが崩れる。これを解決するために、ScrollViewを別のViewで包み、それにConstraintを設定する。
Main.storyboard
次のようにする。Viewを新しく作り、その中にScrollViewを配置する。
margin変数の削除
ViewController
において、marginX
とmarginY
の宣言を削除する。
scrollViewのframe設定
viewDidLoad
において、scrollView.frame
の代入文を以下のように修正する。
scrollView.frame = CGRect(
x: 0,
y: 0,
width: scrollView.superview!.frame.width,
height: scrollView.superview!.frame.height
)