xyk blog

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

CGRect 構造体で使える便利なメソッド

offsetBy

origin の移動

// 右下に移動
let newRect = rect.offsetBy(dx: 10, dy: 10)

insetBy

center は変えずに size の変更

let rect = CGRect(x: 20, y: 20, width: 100, height: 100)

//  縮小する -> (x: 40, y: 40, width: 60, height: 60)
let smallerRect = rect.insetBy(dx: 20, dy: 20)

//  拡大する -> (x: 0, y: 0, width: 140, height: 140)
let largerRect = rect.insetBy(dx: -20, dy: -20)

画像
f:id:xyk:20200124162159j:plain

参考
www.hackingwithswift.com

UIStackView に背景色を設定する

検証環境:
Xcode 11.3
Swift 5.1.3

UIStackView の backgroundColor プロパティに色を設定しても描画されません。
(※追記: iOS 14 から UIStackView の backgroundColor で背景色を付けられるようになりました。)
そこで UIStackView の layer に CAShapeLayer を追加してそこに色を設定します。
UIStackView のサブクラスとして実装します。
IBInspectablecolorプロパティにStoryboard上で色を設定することで確認できるようにしています。

import UIKit

@IBDesignable
class MyStackView: UIStackView {
    
    @IBInspectable
    var color: UIColor?
    
    override var backgroundColor: UIColor? {
        get {
            return color
        }
        set {
            color = newValue
            setNeedsLayout()
        }
    }

    private lazy var backgroundLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        self.layer.insertSublayer(layer, at: 0)
        return layer
    }()
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        CATransaction.begin()
        CATransaction.setDisableActions(true) // CALayerの暗黙的アニメーションは不要なのでオフにする
        
        backgroundLayer.path = UIBezierPath(rect: bounds).cgPath
        backgroundLayer.fillColor = backgroundColor?.cgColor

        CATransaction.commit()
    }
}

さらに UIStackView をタップ中は背景色が少し暗めの色にハイライトするように改良します。
少し暗めの色に変化させるための leap メソッドはこちらの記事で定義したものを使っています。

import UIKit

@IBDesignable
class MyStackView: UIStackView {
    
    @IBInspectable
    var color: UIColor?
    
    override var backgroundColor: UIColor? {
        get {
            guard let color = color else { return nil }
            return isTracking ? color.lerp(to: .gray, progress: 0.3) : color
        }
        set {
            color = newValue
            setNeedsLayout()
        }
    }
    
    private var isTracking: Bool = false {
        didSet {
            setNeedsLayout()
        }
    }
    
    private lazy var backgroundLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        self.layer.insertSublayer(layer, at: 0)
        return layer
    }()
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        
        backgroundLayer.path = UIBezierPath(rect: bounds).cgPath
        backgroundLayer.fillColor = backgroundColor?.cgColor

        CATransaction.commit()
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        isTracking = true
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        isTracking = false
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        isTracking = false
    }
}

UIColor の2色間の中間色を表示する

検証環境:
Xcode 11.3
Swift 5.1.3

2色間の中間色を補完する leap メソッドを UIColor の Extension として追加。
progress で割合を調整します。

import UIKit

extension UIColor {
    
    func lerp(to: UIColor, progress: CGFloat) -> UIColor {
        return .lerp(from: self, to: to, progress: progress)
    }
    
    static func lerp(from: UIColor, to: UIColor, progress: CGFloat) -> UIColor {
        
        let t = max(0, min(1, progress))
        
        var redA: CGFloat = 0
        var greenA: CGFloat = 0
        var blueA: CGFloat = 0
        var alphaA: CGFloat = 0
        from.getRed(&redA, green: &greenA, blue: &blueA, alpha: &alphaA)
        
        var redB: CGFloat = 0
        var greenB: CGFloat = 0
        var blueB: CGFloat = 0
        var alphaB: CGFloat = 0
        to.getRed(&redB, green: &greenB, blue: &blueB, alpha: &alphaB)
        
        let lerp = { (a: CGFloat, b: CGFloat, t: CGFloat) -> CGFloat in
            return a + (b - a) * t
        }
        
        let r = lerp(redA, redB, t)
        let g = lerp(greenA, greenB, t)
        let b = lerp(blueA, blueB, t)
        let a = lerp(alphaA, alphaB, t)
        
        return UIColor(red: r, green: g, blue: b, alpha: a)
    }
}

使い方

// 青色と黄色の中間色を取得する例
let blueYellow = UIColor.blue.leap(to: .yellow, progress: 0.5)
// または
let blueYellow = UIColor.leap(from: .blue, to: .yellow, progress: 0.5)

追記:
以前にも全く同じ内容の記事を書いていた。。。

ある UIColor から別の UIColor に徐々に色を変化させる - xykのブログ

UIColorの色を暗くする - xykのブログ

UIRefreshControl のプルダウンの距離を短くする

検証環境:
Xcode 11.3
Swift 5.1.3

標準 の UIRefreshControl ですがアクションを開始させるためのプルダウンの距離が長いので短くする方法について調べました。
いくつかやり方はあるようですが今回は非推奨なやり方になりますが UIRefreshControl のプライベートプロパティ_snappingHeightを書き換える方法で実装しました。
_snappingHeightにはキー値コーディングでアクセスしますが、将来的にアクセスできなくなってもクラッシュしないように setValue: forUndefinedKeyvalueForUndefinedKeyをオーバーライドしておきます。

import UIKit

class MyRefreshControl: UIRefreshControl {
    
    override func setValue(_ value: Any?, forUndefinedKey key: String) {
        print("Ouch. undefined key: \(key)")
    }
    
    override func value(forUndefinedKey key: String) -> Any? {
        print("Ouch. undefined key: \(key)")
        return nil
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        changeSnappingHeight()
    }
    
    private func changeSnappingHeight() {
        self.setValue(60, forKey: "_snappingHeight")
    }
}

そして ScrollView に追加する。

let refreshControl = MyRefreshControl()
tableView.addSubview(refreshControl)
refreshControl.addTarget(self, action: #selector(loadMoreMessages), for: .valueChanged)

参考:
https://stackoverflow.com/questions/28886365/ https://stackoverflow.com/questions/25699331/

UITextView にプレースホルダーを設定できるようにする

検証環境:
Xcode 11.3
Swift 5.1.3

UITextView にプレースホルダーを設定できるカスタムビューを作成した。

import UIKit

@IBDesignable
open class PlaceHolderTextView: UITextView {
    
    @IBInspectable
    open var placeHolderText: String = "" {
        didSet {
            placeHolderLabel.text = placeHolderText
            updatePlaceHolder()
        }
    }
    
    open var placeHolderLabel: UILabel = .init()
    
    open override func awakeFromNib() {
        super.awakeFromNib()
        configure()
    }

    open override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        configure()
    }
    
    // text プロパティに直接文字列をセットした場合、textViewDidChange は呼ばれないので override している
    open override var text: String! {
        didSet {
            updatePlaceHolder()
        }
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
    
    private func configure() {
        
        placeHolderLabel.lineBreakMode = .byWordWrapping
        placeHolderLabel.numberOfLines = 0
        placeHolderLabel.font = font
        placeHolderLabel.textColor = .lightGray
        addSubview(placeHolderLabel)
        placeHolderLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            placeHolderLabel.topAnchor.constraint(equalTo: topAnchor, constant: textContainerInset.top),
            placeHolderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: textContainer.lineFragmentPadding),
            placeHolderLabel.widthAnchor.constraint(equalTo: widthAnchor)
        ])
        
        updatePlaceHolder()
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textViewDidChange(_:)),
            name: UITextView.textDidChangeNotification,
            object: nil)
    }
    
    private func updatePlaceHolder() {
        placeHolderLabel.isHidden = placeHolderText.isEmpty || !text.isEmpty
    }
    
    @objc private func textViewDidChange(_ notification: Notification) {
        updatePlaceHolder()
    }
}

Swift で端末の低電力モード、Appのバックグラウンド更新の状態を取得する

検証環境:
Xcode 11.3
Swift 5.1.3

「低電力モード」になっているかを取得

if ProcessInfo.processInfo.isLowPowerModeEnabled {
    // Low Power Mode is enabled. Start reducing activity to conserve energy.
} else {
    // Low Power Mode is not enabled.
}

https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/LowPowerMode.html

「Appのバックグラウンド更新」の状態を取得

このステータスはアプリ毎に設定値を持つ。
以下画像はLINEアプリの例。

f:id:xyk:20200214145849j:plain
switch UIApplication.shared.backgroundRefreshStatus {
case .available:
    print("Refresh available")
case .denied:
    print("Refresh denied")
case .restricted:
    print("Refresh restricted")
}

UIBackgroundRefreshStatus の定義

@available(iOS 7.0, *)
public enum UIBackgroundRefreshStatus : Int {

    case restricted //< unavailable on this system due to device configuration; the user cannot enable the feature

    case denied //< explicitly disabled by the user for this application

    case available //< enabled for this application
}

https://developer.apple.com/documentation/uikit/uiapplication/1622994-backgroundrefreshstatus

UITableViewCell のタップした位置の IndexPath を取得する

検証環境:
Xcode 11.3
Swift 5.1.3

UITableViewCell 上に置いたボタンをタップしたときにそのセルをアニメーション削除したい。
UITableViewDataSource プロトコルの cellForRowAt メソッドにセルのコールバックプロパティに引数の indexPath を渡す実装にしたところ、セル削除時に Index out of range が発生してクラッシュした。
原因はキャッシュされた indexPath を使っているためで、例えば2つセルがあって、先に1つ目を消し、その後2つ目を消すと存在しない index を指定することになるため。

修正前

// セル
class MyCell: UITableViewCell {

    var tapButtonHandler: (() -> Void)?
    
    @IBAction func handleButton(_ sender: UIButton) {
        tapButtonHandler?(event)
    }
}

// UITableViewDataSource
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath) as! MyCell
    cell.tapButtonHandler = { [weak self] in
        self?.items.remove(at: indexPath.row)
        tableView.deleteRows(at: [indexPath], with: .fade)
    }
    return cell
}

タップした位置に存在する IndexPath を取得するように修正した。

  1. UIEvent から UITouch を取得
  2. UITouch の locationInView メソッドで TableView 上の CGPoint 取得
  3. UITableView の indexPathForRowAtPoint メソッドで CGPoint が含まれる IndexPath 取得

という流れ。

修正後

// セル
class MyCell: UITableViewCell {

    var tapButtonHandler: ((UIEvent) -> Void)?
    
    @IBAction func handleButton(_ sender: UIButton, event: UIEvent) {
        tapButtonHandler?(event)
    }
}

// UITableViewDataSource
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath) as! MyCell
    cell.tapButtonHandler = { [weak self] event in
        guard let self = self else { return }
        if let touch = event.allTouches?.first {
            let point = touch.location(in: self.tableView)
            if let selectedIndexPath = tableView.indexPathForRow(at: point) {
                self.items.remove(at: selectedIndexPath.row)
                tableView.deleteRows(at: [selectedIndexPath], with: .fade)
            }
        }
    }
    return cell
}

プログラムから iPhone 画面の明るさを取得、変更する

検証環境:
Xcode 11.2.1
Swift 5.1.2

iPhone 画面の明るさは UIScreen.main.brightness プロパティから取得できます。
また、UIScreen.main.brightness プロパティに 0.0 から 1.0 の値を設定することで明るさをプログラムから変更できます。
1.0 が最も明るくなります。

brightness - UIScreen | Apple Developer Documentation

以下サンプルコードは特定の画面(UIViewController)でのみ明るさを最大にする例を RxSwift を使って実装しています。
QRコード決済アプリなどでよくやっているやつですね。
その後、その画面を閉じる、またはその画面からアプリをバックグラウンドにしたタイミングで元の明るさに戻しています。

import UIKit
import RxSwift

class QRCodeViewController: UIViewController {

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupScreenBrightness()
        
        // QRコードを表示
        // ...
    }
    
    private func setupScreenBrightness() {
        Observable.merge(
            NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification).map { _ in true },
            NotificationCenter.default.rx.notification(UIApplication.willResignActiveNotification).map { _ in false },
            rx.sentMessage(#selector(viewWillAppear)).map { _ in true },
            rx.sentMessage(#selector(viewWillDisappear)).map { _ in false }
        )
        .subscribe(onNext: { [weak self] active in
            self?.updateScreenBrightness(full: active)
        }).disposed(by: disposeBag)
    }
    
    private var currentBrightness: CGFloat?
    
    private func updateScreenBrightness(full: Bool) {
        if full {
            currentBrightness = UIScreen.main.brightness
            UIScreen.main.brightness = 1.0
        } else {
            if let currentBrightness = currentBrightness {
                UIScreen.main.brightness = currentBrightness
            }
            currentBrightness = nil
        }
    }
}

iOS13 で UITableViewCell をタップしても背景色がハイライトしなくなった

検証環境:
Xcode 11.1
Swift 5.1

iOS13 で UITableViewCell をタップしてもハイライトしなくなったケースがあった。
原因はこちらの UIKit の仕様変更だった。

Apple Developer Documentation

The UITableViewCell class no longer changes the backgroundColor or isOpaque properties of the contentView and any of its subviews when cells become highlighted or selected. If you are setting an opaque backgroundColor on any subviews of the cell inside (and including) the contentView, the appearance when the cell becomes highlighted or selected might be affected. The simplest way to resolve any issues with your subviews is to ensure their backgroundColor is set to nil or clear, and their opaque property is false. However, if needed you can override the setHighlighted(:animated:) and setSelected(:animated:) methods to manually change these properties on your subviews when moving to or from the highlighted and selected states. (13955336)

UITableViewCell 選択時に contentView 及びそのサブビューの backgroundColor または isOpaque プロパティが変更されなくなり、 contentView 及びそのサブビューに不透明な backgroundColor を設定している場合には影響を受けてハイライト表示されなくなる。

これを解決するには ハイライトさせたいビューの backgroundColornil または clear に設定し、isOpaque プロパティを false にすればよいとのこと。

なんだけど isOpaque プロパティは true のままでも backgroundColornil または clear にすればハイライトするようになった。

Swift で Dictionary をマージする

検証環境:
Xcode 11.1
Swift 5.1

Dictionary の mergeまたはmergingメソッドを使う。

// merging
do {
    let a = ["a": 100]
    let b = ["a": 200, "b": 300]

    // 重複したKeyは a を優先させる場合
    let result1 = a.merging(b) { (a, b) in a }
    print(result1) // ["a": 100, "b": 300]
    
    // 重複したKeyは b を優先させる場合
    let result2 = a.merging(b) { (a, b) in b }
    print(result2) // ["a": 200, "b": 300]
}

// merge
do {
    var a = ["a": 100]
    let b = ["a": 200, "b": 300]

    a.merge(b) { (a, b) in a }
    print(a) // ["a": 100, "b": 300]
}

// + オペレータを使う
do {
    // 重複したKeyはValueを合計する
    let a = ["a": 100]
    let b = ["a": 200, "b": 300]

    let result = a.merging(b, uniquingKeysWith: +)
    print(result) // ["b": 300, "a": 300]
}

merge(_:uniquingKeysWith:) - Dictionary | Apple Developer Documentation

merging(_:uniquingKeysWith:) - Dictionary | Apple Developer Documentation