xyk blog

最近は iOS 開発の記事が多めです。

Swift で少数第二位や第三位で丸め処理を行う

検証環境:
Xcode 12.4
Swift 5.3.2

Swift で少数第二位や第三位で丸め処理を行う方法について。
例えば第二位で四捨五入したいのであれば、対象の少数値にまず10を掛けてからrounded()で四捨五入し、その後に10で割ればよい。

実行例

let pi = Double(3.1415)

// 少数第1位で四捨五入する
let ret1 = pi.rounded() // 3

// 少数第2位で四捨五入する
let ret2 = (pi * 10).rounded() / 10  // 3.1

// 少数第3位で四捨五入する
let ret3 = (pi * 100).rounded() / 100 // 3.14

// 少数第4位で四捨五入する
let ret4 = (pi * 1000).rounded() / 1000 // 3.142

rounded メソッドによる丸めのルールは前回の記事を参照。

xyk.hatenablog.com

Swift の Float(CGFloat) や Double で小数点の丸め処理を行う

検証環境:
Xcode 12.4
Swift 5.3.2

Swift の浮動小数点数型である Float(CGFloat) や Double で小数点の切り捨て、切り上げ、四捨五入などの丸め処理をするには Swift3 から追加された FloatingPoint プロトコルの extension に定義されている roundedメソッドを使えばよい。
(roundメソッドもあるが、roundedが非破壊メソッドであるのに対しroundは破壊メソッドとなる)
ちなみに以前はceil(切り捨て)、floor(切り上げ)、round(四捨五入)のグローバルな関数を使っていた。

引数なし rounded

Float

func rounded() -> Float

Double

func rounded() -> Double

引数なし rounded メソッドは四捨五入される。
0.5より小さければ切り捨て、0.5より大きければ切り上げ、ちょうど0.5なら0から遠い方の数値へ丸める。

実行例

(5.2).rounded()
// 5.0
(5.5).rounded()
// 6.0
(-5.2).rounded()
// -5.0
(-5.5).rounded()
// -6.0

引数あり rounded

Float

func rounded(_ rule: FloatingPointRoundingRule) -> Float

Double

func rounded(_ rule: FloatingPointRoundingRule) -> Double

引数の enum である FloatingPointRoundingRule には

  • up
  • down
  • toNearestOrAwayFromZero
  • toNearestOrEven
  • towardZero
  • awayFromZero

の6つが定義されている。

rounded(.up)

切り上げ。大きい方の数値に丸める。

実行例

(5.2).rounded(.up)
// 6.0
(5.5).rounded(.up)
// 6.0
(-5.2).rounded(.up)
// -5.0
(-5.5).rounded(.up)
// -5.0

rounded(.down)

切り下げ。小さい方の数値に丸める。

実行例

(5.2).rounded(.down)
// 5.0
(5.5).rounded(.down)
// 5.0
(-5.2).rounded(.down)
// -6.0
(-5.5).rounded(.down)
// -6.0

rounded(.towardZero)

0に近い数値に丸める。

実行例

(5.2).rounded(.towardZero)
// 5.0
(5.5).rounded(.towardZero)
// 5.0
(-5.2).rounded(.towardZero)
// -5.0
(-5.5).rounded(.towardZero)
// -5.0

rounded(.awayFromZero)

0から遠い数値に丸める。

実行例

(5.2).rounded(.awayFromZero)
// 6.0
(5.5).rounded(.awayFromZero)
// 6.0
(-5.2).rounded(.awayFromZero)
// -6.0
(-5.5).rounded(.awayFromZero)
// -6.0

rounded(.toNearestOrAwayFromZero)

schoolbook rounding(教科書の丸め?)と呼ばれる丸め規則に従う。[参照]

最も近い許容値に丸めます。2つの値が等しく近い場合は、大きさが大きい方が選択されます。

0.5より小さければ切り捨て、0.5より大きければ切り上げ、ちょうど0.5なら0から遠い(AwayFromZero)方の数値へ丸める。

こちらは引数なしのrounded()と同じ挙動であり四捨五入となる。

実行例

(5.2).rounded(.toNearestOrAwayFromZero)
// 5.0
(5.5).rounded(.toNearestOrAwayFromZero)
// 6.0
(-5.2).rounded(.toNearestOrAwayFromZero)
// -5.0
(-5.5).rounded(.toNearestOrAwayFromZero)
// -6.0

rounded(.toNearestOrEven)

bankers rounding(銀行家の丸め?)と呼ばれる丸め規則に従う。[参照]

最も近い許容値に丸めます。2つの値が等しく近い場合は、偶数の値が選択されます。

0.5より小さければ切り捨て、0.5より大きければ切り上げ、ちょうど0.5なら偶数(Even)となる方の数値へ丸める。

実行例

(5.2).rounded(.toNearestOrEven)
// 5.0
(5.5).rounded(.toNearestOrEven)
// 6.0
(4.5).rounded(.toNearestOrEven)
// 4.0
(-5.2).rounded(.toNearestOrEven)
// -5.0
(-5.5).rounded(.toNearestOrEven)
// -6.0
(-4.5).rounded(.toNearestOrEven)
// -4.0

参考

Float
developer.apple.com

CGFloat
developer.apple.com

Double
developer.apple.com

Proposal github.com

UITabBar の特定タブの選択時の色を変える

検証環境:
Xcode 12.4
Swift 5.3.2
iOS Deployment Target 14.4

UITabBar 使用時に、特定タブ(UITabBarItem)のみ、選択時の色を変える方法について。

2つ目のタブを赤に変更
f:id:xyk:20210410130748p:plain

その他のタブはデフォルトの tint color
f:id:xyk:20210410130858p:plain

タイトル文字色はtabBarItem.setTitleTextAttributesで指定、
画像色はtabBarItem.selectedImageにセットする UIImage の withRenderingMode に .alwaysOriginal を指定することで変更できる。
UITabBarController のサブクラスを作ってその中の viewDidLoad で変更している。

サンプルコード

class TabBarController: UITabBarController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let targetIndex = 1 // 変えたいタブの場所。今回は2つ目とする。
        
        if let tabBarItems = tabBar.items, tabBarItems.indices.contains(targetIndex) {
            let tabBarItem = tabBarItems[targetIndex]
            
            // タイトル色の変更
            tabBarItem.setTitleTextAttributes([.foregroundColor: UIColor.systemRed], for: .selected)
            
            // 画像色の変更
            let image = tabBarItem.image! // 元の画像を取得
            let tintedImage = image.tinted(with: .systemRed).withRenderingMode(.alwaysOriginal) // 画像の色変更とRenderingMode指定
            tabBarItem.selectedImage = tintedImage
        }
    }
}

// 画像の色を変える UIImage Extension
extension UIImage {
    
    func tinted(with color: UIColor) -> UIImage {
        return UIGraphicsImageRenderer(size: size).image { context in
            let rect = CGRect(origin: .zero, size: size)
            draw(in: rect)
            color.setFill()
            context.fill(rect, blendMode: .sourceIn)
        }
    }
}

StoaryBoard側でタブの画像、タイトルは設定済み。
UITabBarController にこのカスタムクラスを設定する。
UIImageの色を変える方法については以下の記事で書いた。

xyk.hatenablog.com

iOS14 での CLLocationManager の変更点

検証環境:
Xcode 12.4
Swift 5.3.2
iOS Deployment Target 14.4

f:id:xyk:20210409193347p:plain

iOS で位置情報を取得するにはCLLocationManagerを使用するのだが、iOS14から使い方が少し変更になったのでメモ。

1つ目がアプリが位置情報サービスの使用するための承認ステータスauthorizationStatusが、以前はクラスメソッドの CLLocationManager.authorizationStatus()
を使って取得していたがこれが Deprecated になり、iOS14からは CLLocationManager のプロパティ
locationManager.authorizationStatus
に変更された。

2つ目が承認ステータスに変更があった時に呼び出される CLLocationManager のデリゲートメソッド
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus)
を使っていたがこれが Deprecated になり、iOS14からは
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager)
を使うことになった。

iOS14 で現在位置を取得する簡単なサンプルコード

import UIKit
import CoreLocation

class ViewController: UIViewController {
    
    let locationManager = CLLocationManager()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        locationManager.delegate = self
        
        if CLLocationManager.locationServicesEnabled() {
            locationManager.requestWhenInUseAuthorization() // 位置情報サービスを使用するために許可を要求する
        } else {
            print("位置情報の使用を許可してください")
        }
    }
}

extension ViewController: CLLocationManagerDelegate {
    
    // ↓iOS14から追加された新しいデリゲートメソッド
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    
        // ↓iOS14から追加された新しいプロパティ   
        switch manager.authorizationStatus {
        case .notDetermined: // 初回呼び出し時、設定で次回確認を選択時
            print("notDetermined")
            break
        case .restricted: // ペアレンタルコントロールなどの制限あり
            print("restricted")
            break
        case .denied: // 使用拒否した
            print("denied")
            break
        case .authorizedAlways: // いつでも位置情報サービスを開始することを許可した
            print("authorizedAlways")
            manager.startUpdatingLocation() // 位置情報の取得開始
            break
        case .authorizedWhenInUse: // アプリ使用中のみ位置情報サービスを開始することを許可した
            print("authorizedWhenInUse")
            manager.startUpdatingLocation() // 位置情報の取得開始
        @unknown default:
            break
        }
        
        // iOS14から追加された位置情報精度
        switch manager.accuracyAuthorization {
        case .fullAccuracy: // 正確な位置情報
            print("fullAccuracy")
            break
        case .reducedAccuracy: // おおよその位置情報
            print("reducedAccuracy")
            break
        @unknown default:
            break
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        print("didUpdateLocations: \(locations)") //  位置情報の取得成功
        manager.stopUpdatingLocation() // 位置情報の収集終了
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("didFailWithError: \(error)") //  位置情報の取得失敗
    }
}

Info.plist に

  • Privacy - Location When In Use Usage Description
    NSLocationWhenInUseUsageDescription

または

  • Privacy - Location Always and When In Use Usage Description
    NSLocationAlwaysAndWhenInUseUsageDescription

の追加も忘れずに。

MKAnnotation ピンの画像をカスタマイズする

検証環境:
Xcode 12.4
Swift 5.3.2
iOS Deployment Target 14.4

マップ(MKMapView)上のピンの画像をデフォルトのものではなく別の画像にカスタマイズする方法について。

iOS11からマップビューにデフォルトでMKMarkerAnnotationViewが登録されているので、mapView.dequeueReusableAnnotationViewで取得し、glyphImageプロパティに画像を設定すればよい。
画像は iOS13 から追加された SF Symbols の画像を使って置き換えた。
またピンの色はmarkerTintColorを設定することで変更できる。

f:id:xyk:20210408132137p:plain

コードは前回のサンプルコードに追記していく形で。
xyk.hatenablog.com

サンプルコード

import UIKit
import MapKit

class ViewController: UIViewController {

    let mapView = MKMapView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(mapView)
        mapView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            mapView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            mapView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor),
            mapView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor),
        ])
        
        mapView.delegate = self
        
        let center = CLLocationCoordinate2D(latitude: 35.68358493824179, longitude: 139.750327090532)
        mapView.region = MKCoordinateRegion(center: center, latitudinalMeters: 500, longitudinalMeters: 500)
        
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))
        longPress.minimumPressDuration = 0.5
        mapView.addGestureRecognizer(longPress)
    }
    
    @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        
        let annotation = MKPointAnnotation()
        let location = gesture.location(in: mapView)
        let coordinate = mapView.convert(location, toCoordinateFrom: mapView)
        annotation.coordinate = coordinate
        annotation.title = "\(mapView.annotations.count)"
        mapView.addAnnotation(annotation)
    }
}

extension ViewController: MKMapViewDelegate {
    
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        
        let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: MKMapViewDefaultAnnotationViewReuseIdentifier, for: annotation) as! MKMarkerAnnotationView
        annotationView.displayPriority = .required
        annotationView.glyphImage = UIImage(systemName: "flame")! // SF Symbols の画像を使用
        annotationView.markerTintColor = selectTintColor(annotation) // 色の変更
        return annotationView
    }
    
    private func selectTintColor(_ annotation: MKAnnotation?) -> UIColor? {
        guard let annotation = annotation as? MKPointAnnotation else { return nil }
        let colors: [UIColor] = [.systemRed, .systemBlue, .systemYellow, .systemGreen]
        let index = Int(annotation.title ?? "") ?? 0
        let remainder = index % colors.count
        return colors[remainder]
    }
}

MKMarkerAnnotationView を継承したカスタムクラスを用意した場合のサンプルコード

カスタムクラス内で画像や色を設定すれば MKMapViewDelegate の実装は不要。

import UIKit
import MapKit

class ViewController: UIViewController {

    let mapView = MKMapView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(mapView)
        mapView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            mapView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            mapView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor),
            mapView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor),
        ])
        
        let center = CLLocationCoordinate2D(latitude: 35.68358493824179, longitude: 139.750327090532)
        mapView.region = MKCoordinateRegion(center: center, latitudinalMeters: 500, longitudinalMeters: 500)
        
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))
        longPress.minimumPressDuration = 0.5
        mapView.addGestureRecognizer(longPress)
        
        // カスタムアノテーションビューを登録
        mapView.register(CustomAnnotationView.self, forAnnotationViewWithReuseIdentifier: MKMapViewDefaultAnnotationViewReuseIdentifier)
    }
    
    @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        
        let annotation = MKPointAnnotation()
        let location = gesture.location(in: mapView)
        let coordinate = mapView.convert(location, toCoordinateFrom: mapView)
        annotation.coordinate = coordinate
        annotation.title = "\(mapView.annotations.count)"
        mapView.addAnnotation(annotation)
    }
}

// カスタムアノテーションビューの定義
class CustomAnnotationView: MKMarkerAnnotationView {
    
    override var annotation: MKAnnotation? {
        didSet {
            configure(for: annotation)
        }
    }
    
    override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
        super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)

        glyphImage = UIImage(systemName: "flame")!
        
        configure(for: annotation)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(for annotation: MKAnnotation?) {
        displayPriority = .required
        markerTintColor = selectTintColor(annotation)
    }
    
    private func selectTintColor(_ annotation: MKAnnotation?) -> UIColor? {
        guard let annotation = annotation as? MKPointAnnotation else { return nil }
        let colors: [UIColor] = [.systemRed, .systemBlue, .systemYellow, .systemGreen]
        let index = Int(annotation.title ?? "") ?? 0
        let remainder = index % colors.count
        return colors[remainder]
    }
}

マップ(MKMapView)上にピンを立てる

検証環境:
Xcode 12.4
Swift 5.3.2
iOS Deployment Target 14.4

マップ(MKMapView)を表示してピンを立てるだけの簡単な実装例。

マップビューにピンを追加するには、MKAnnotationプロトコルを適合したクラスを用意して、位置情報CLLocationCoordinate2Dを設定する。
そしてそのアノテーションをマップビューに追加すればよい。

以下サンプルでは、デフォルトで用意されているアノテーションクラスのMKPointAnnotationを使用し、マップ上で長押しした地点にピンを立てるようにした。

f:id:xyk:20210408120838p:plain

import UIKit
import MapKit

class ViewController: UIViewController {

    let mapView = MKMapView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // マップビュー張り付け
        view.addSubview(mapView)
        mapView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            mapView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            mapView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            mapView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor),
            mapView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor),
        ])
        
        mapView.delegate = self
        
        // マップの中心緯度経度と表示範囲を指定
        let center = CLLocationCoordinate2D(latitude: 35.68358493824179, longitude: 139.750327090532)
        mapView.region = MKCoordinateRegion(center: center, latitudinalMeters: 500, longitudinalMeters: 500)
        
        // マップ上の長押しした地点にピンを立てるジェスチャーを追加
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))
        longPress.minimumPressDuration = 0.5
        mapView.addGestureRecognizer(longPress)
    }
    
    @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) {
        guard gesture.state == .began else { return }
        
        // MKPointAnnotation を生成してマップビューに追加する
        let annotation = MKPointAnnotation()
        let location = gesture.location(in: mapView)
        let coordinate = mapView.convert(location, toCoordinateFrom: mapView)
        annotation.coordinate = coordinate
        annotation.title = "\(mapView.annotations.count + 1)"
        mapView.addAnnotation(annotation)
    }
}

extension ViewController: MKMapViewDelegate {
    
    // ピンのビューである MKAnnotationView に変更を加えるためのデリゲートメソッド。変更する必要がなければこのデリゲートは省略してもよい。
    func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {

        let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: MKMapViewDefaultAnnotationViewReuseIdentifier, for: annotation)
        annotationView.displayPriority = .required // 縮小時にピンを省略せず表示させる
        return annotationView
    }
}

git で別ブランチから特定のファイルを取得する

検証環境:
git version 2.23.0

git で別ブランチから特定のファイルを取得する方法について。

ステージされている状態でファイルを取得する

$ git checkout <別ブランチ名> path/to/file

ステージされていない状態でファイルを取得する

$ git show <別ブランチ名>:path/to/file > path/to/file

git show で参照できるので、それをリダイレクトでファイル出力してる。

Swift - Struct のイニシャライザを Xcode で自動生成する

検証環境:
Xcode 12.4
Swift 5.3.2

Swift の Struct にはデフォルトで暗黙的なイニシャライザ init メソッドが定義されているので自分で実装する必要ないのだが、public structで定義して別モジュールから import して使う場合は使用できない。

以下の Initializers を参照。
Access Control — The Swift Programming Language (Swift 5.4)

なので初期化用の init メソッドを自分で実装しなければならないのだが、メンバ変数がたくさんあると面倒である。
調べたところ Xcode に自動生成する機能をあったのでそれを使えば楽に生成できた。

やり方

以下のような Struct を定義したとする。

f:id:xyk:20210325121207p:plain

Struct 名を右クリックし、Refactor -> Generate Memberwise Initializer を選択。

f:id:xyk:20210325121217p:plain

イニシャライザのコードが自動生成された。

f:id:xyk:20210325121227p:plain

Swift - UIBezierPath でクーポン風画像のパスを描く

検証環境:
Xcode 12.4
Swift 5.3.2

UIBezierPath のよるお絵描きシリーズ。
今回は UIBezierPath を使って、以下のようなクーポン用画像としてよく使われる図形をを描いてみる。

Playground コードサンプル

import UIKit
import PlaygroundSupport

class CouponFrameView: UIView {
    
    private let lineWidth: CGFloat = 8
    private let strokeColor = UIColor.systemYellow
    
    private lazy var borderLayer: CAShapeLayer = {
        let shapeLayer = CAShapeLayer()
        shapeLayer.lineWidth = lineWidth
        shapeLayer.strokeColor = strokeColor.cgColor
        shapeLayer.fillColor = UIColor.white.cgColor
        shapeLayer.lineJoin = .round // パスの接続部分を丸くする
        return shapeLayer
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        configure()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        borderLayer.path = makeCouoponPath(rect: bounds)
    }
    
    private func configure() {
        // layer.backgroundColor = UIColor.systemGray4.cgColor // 矩形確認用
        layer.addSublayer(borderLayer)
    }
    
    private func makeCouoponPath(rect: CGRect) -> CGPath {
        
        let half = lineWidth / 2
        let rect = rect.insetBy(dx: half, dy: half) // 矩形内に収まるように線幅の半分小さくする
        
        let cornerRadius: CGFloat = 32
        let hollowRadius: CGFloat = 16

        let path = UIBezierPath()
        path.move(to: CGPoint(x: rect.minX + cornerRadius, y: rect.minY)) // 1
        path.addLine(to: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY)) // 2
        path.addArc(withCenter: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius), // 3
                    radius: cornerRadius,
                    startAngle: -radian(90),
                    endAngle: 0,
                    clockwise: true)
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY - hollowRadius)) // 4
        path.addArc(withCenter: CGPoint(x: rect.maxX, y: rect.midY), // 5
                    radius: hollowRadius,
                    startAngle: -radian(90),
                    endAngle: radian(90),
                    clockwise: false)
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cornerRadius)) // 6
        path.addArc(withCenter: CGPoint(x: rect.maxX - cornerRadius, y: rect.maxY - cornerRadius), // 7
                    radius: cornerRadius,
                    startAngle: 0,
                    endAngle: radian(90),
                    clockwise: true)
        path.addLine(to: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY)) // 8
        path.addArc(withCenter: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius), // 9
                    radius: cornerRadius,
                    startAngle: radian(90),
                    endAngle: radian(180),
                    clockwise: true)
        path.addLine(to: CGPoint(x: rect.minX, y: rect.midY + hollowRadius)) // 10
        path.addArc(withCenter: CGPoint(x: rect.minX, y: rect.midY), // 11
                    radius: hollowRadius,
                    startAngle: radian(90),
                    endAngle: -radian(90),
                    clockwise: false)
        path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius)) // 12
        path.addArc(withCenter: CGPoint(x: rect.minX + cornerRadius, y: rect.minY + cornerRadius), // 13
                    radius: cornerRadius,
                    startAngle: radian(180),
                    endAngle: -radian(90),
                    clockwise: true)
        path.close()
        
        return path.cgPath
    }
    
    private func radian(_ degree: CGFloat) -> CGFloat {
        return degree * .pi / 180.0
    }
}

class MyViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemGray5
        
        let couponFrameView = CouponFrameView()
        view.addSubview(couponFrameView)
        couponFrameView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            couponFrameView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            couponFrameView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            couponFrameView.widthAnchor.constraint(equalToConstant: 500),
            couponFrameView.heightAnchor.constraint(equalToConstant: 200),
        ])
    }
}

PlaygroundPage.current.liveView = MyViewController()

パスの描画順序

その他の UIBezierPath シリーズ

xyk.hatenablog.com

xyk.hatenablog.com

UICollectionViewCell の Self-Sizing で動的にセルサイズを調整する

検証環境:
Xcode 12.4
Swift 5.3.2

UICollectionViewCell の Self-Sizing 機能でコンテンツに基づいて動的にセルサイズを調整する方法について。

まず今回 UICollectionView を使って実現したいレイアウトは横幅が画面幅(collectionView.frame.width)で、縦幅はセル内のコンテンツが可変であるためそれが収まるように動的に決まり、縦スクロールする UITableView のようなレイアウト。

セルサイズを自分で計算するのではなく、 Self-Sizing 機能を使って自動で調整されるようにしたい。

f:id:xyk:20210226130516p:plain

StoaryBoard 設定

  • StoryBoard で Root View に UICollectionView 及び UICollectionViewCell のビューを置く。
    UICollectionView と UICollectionViewDelegateFlowLayout は @IBOutlet でコード上の定義と接続する。

  • UICollectionViewCell の ContentView には Lines 0 の UILabel を置く。
    UILabel に ContentView の4辺と合わせる制約と、暫定の width の制約を付ける。
    そして UILabel と UILabel の width 制約は @IBOutlet でコード上の定義 maxWidthConstraint と接続する。

f:id:xyk:20210226131806p:plain

コードサンプル

セルフサイジングを有効にするにはUICollectionViewFlowLayout.estimatedItemSizeに 0 以外の値、通常はUICollectionViewFlowLayout.automaticSizeを設定する必要がある。
UICollectionViewFlowLayout.itemSizeUICollectionViewDelegateFlowLayout#collectionView(_:layout:sizeForItemAt:)デリゲートメソッドによるセルサイズ指定は不要。

import UIKit

final class ViewController: UIViewController {
    
    var items: [String] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        for _ in 0..<10 {
            items.append(String(repeating: "あいうえおかきくけこ", count: .random(in: 1...10)))
        }
    }
    
    @IBOutlet weak var collectionView: UICollectionView! {
        didSet {
            collectionView.dataSource = self
            collectionView.delegate = self
            collectionView.alwaysBounceVertical = true
        }
    }
    
    @IBOutlet weak var flowLayout: UICollectionViewFlowLayout! {
        didSet {
            flowLayout.minimumLineSpacing = 8
            flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
            flowLayout.scrollDirection = .vertical
            flowLayout.sectionInset = .init(top: 8, left: 0, bottom: 8, right: 0)
        }
    }
}

extension ViewController: UICollectionViewDataSource {
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell", for: indexPath) as! CollectionViewCell
        cell.contentView.backgroundColor = (indexPath.item % 2 == 0) ? .systemGray4 : .systemGray6
        let item = items[indexPath.item]
        cell.titleLabel.text = item
        cell.maxWidth = collectionView.bounds.width - 16
        return cell
    }
}

extension ViewController: UICollectionViewDelegate {
    
}

class CollectionViewCell: UICollectionViewCell {
    
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var maxWidthConstraint: NSLayoutConstraint!
    
    var maxWidth: CGFloat? {
        didSet {
            guard let maxWidth = maxWidth else { return }
            maxWidthConstraint.constant = maxWidth
        }
    }
    
    override func awakeFromNib() {
        super.awakeFromNib()
        
        // iOS12のみ以下制約をつけないとAutoLayoutが効かない
        contentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            contentView.leftAnchor.constraint(equalTo: leftAnchor),
            contentView.rightAnchor.constraint(equalTo: rightAnchor),
            contentView.topAnchor.constraint(equalTo: topAnchor),
            contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
}