xyk blog

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

SwiftでON・OFFの切り替えをする円形ボタンを作る

環境: Swift3

f:id:xyk:20170123143938g:plain

こんな感じの円形ボタンのカスタムビューを作る。
ボタンというよりUISwitch的なON・OFFの状態切り替えをさせたい。
UIControlを継承して、状態はisSelectedプロパティで保持している。

import UIKit
import PlaygroundSupport

final class CircleView: UIControl {

    var didTouchUpInsideHandler: (() -> Void)?
    
    let normalColor = UIColor(hex: 0x59acff)
    let selectedColor = UIColor(hex: 0xFF6E86)

    var circleColor: UIColor {
        return self.isSelected ? self.selectedColor : self.normalColor
    }

    // タッチの反応を円内のみとする
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        return self.circleShapeLayer.path?.contains(point) ?? false
    }

    // タッチ時、離れた時に呼ばれる
    override var isHighlighted: Bool {
        didSet {
            guard oldValue != self.isHighlighted else { return }
            
            self.circleShapeLayer.fillColor = self.isHighlighted ?
                self.circleColor.darkColor().cgColor : self.circleColor.cgColor

            if self.isHighlighted {
                UIView.animate(
                    withDuration: 0.05,
                    delay: 0,
                    options: [.allowUserInteraction, .beginFromCurrentState],
                    animations: { [weak self] in
                        self?.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
                })
            } else {
                UIView.animate(
                    withDuration: 1,
                    delay: 0,
                    usingSpringWithDamping: 0.15,
                    initialSpringVelocity: 10,
                    options: [.allowUserInteraction, .beginFromCurrentState],
                    animations: { [weak self] in
                        self?.transform = CGAffineTransform.identity
                })
            }
        }
    }
    
    // 円の描画
    override func layoutSublayers(of layer: CALayer) {
        super.layoutSublayers(of: layer)
        
        if self.circleShapeLayer.superlayer == nil {
            self.layer.insertSublayer(self.circleShapeLayer, at: 0)
        }
    }
    
    // 円のlayer作成
    lazy var circleShapeLayer: CAShapeLayer = { [unowned self] in
        
        let path = UIBezierPath(ovalIn: self.bounds)
        
        let shapeLayer = CAShapeLayer()
        shapeLayer.fillColor = self.circleColor.cgColor
        shapeLayer.path = path.cgPath
        
        return shapeLayer
    }()

    // タッチを離した時
    override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
        super.endTracking(touch, with: event)
        
        // 円内で離した場合のみに反応させる
        if let point = touch?.location(in: self),
            let path = self.circleShapeLayer.path,
            path.contains(point) {
            
            self.isSelected = !self.isSelected
            self.didTouchUpInsideHandler?()
        }
    }
}

extension UIColor {
    
    convenience init(hex: UInt32, alpha: CGFloat = 1.0) {
        let mask = 0x000000FF
        
        let r = Int(hex >> 16) & mask
        let g = Int(hex >> 8) & mask
        let b = Int(hex) & mask
        
        let red   = CGFloat(r) / 255
        let green = CGFloat(g) / 255
        let blue  = CGFloat(b) / 255
        
        self.init(red:red, green:green, blue:blue, alpha: alpha)
    }
    
    // 暗めの色にする
    func darkColor(brightnessRatio: CGFloat = 0.8) -> UIColor {
        
        var hue: CGFloat = 0
        var saturation: CGFloat = 0
        var brightness: CGFloat = 0
        var alpha: CGFloat = 0
        
        if self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
            return UIColor(hue: hue, saturation: saturation, brightness: brightness * brightnessRatio, alpha: alpha)
        } else {
            return self
        }
    }
}

let baseView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
baseView.backgroundColor = .white

let view = CircleView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
view.center = baseView.center
baseView.addSubview(view)

PlaygroundPage.current.liveView = baseView

Storyboardを使ってUITableViewを組み立てる場合のテンプレート(Swift3)

環境: Swift3

よく使うのでコピペ用にメモしておく。

ViewController

import UIKit

class ViewController: UIViewController {

    var items: [String] = ["foo", "bar", "hoge"]
    
    @IBOutlet weak var tableView: UITableView?
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

extension ViewController: UITableViewDataSource, UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell", for: indexPath) as! MyCell
        cell.item = self.items[indexPath.row]
        return cell
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 60
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
    
}

final class MyCell: UITableViewCell {
    
    var item: String? {
        didSet {
            self.nameLabel?.text = self.item
        }
    }

    override func awakeFromNib() {
        super.awakeFromNib()
    }

    @IBOutlet weak var nameLabel: UILabel?
}

カスタムセルMyCellを定義する。
Storyboard 上でカスタムセルを貼り付けて定義している場合は以下のような register メソッドは不要。
逆にregister メソッドで登録してしまうとカスタムセルが表示されなくなってしまうので注意。
カスタムセルをコードのみで定義、または別途 nib ファイルで定義した場合は viewDidLoad などで以下のように register メソッドで登録する。

self.tableView?.register(MyCell.self, forCellReuseIdentifier: "MyCell")
self.tableView?.register(UINib(nibName: "MyCell", bundle: nil), forCellReuseIdentifier: "MyCell")

また、普通やらないと思うが、カスタムセルを使う場合に UITableViewCell にデフォルトで用意されているプロパティ(textLabelなど)を使うと表示がおかしくなるのでやらないこと。

Storyboard

UIViewController に UITableView と UITableViewCell を貼り付ける。
UITableView の datasource と delegate を UIViewController に接続する。
UITableViewCell のクラス名を設定、Identifier を設定。
UITableViewCell 上にラベルなどがあればそれも接続する。

f:id:xyk:20170115202101p:plain f:id:xyk:20170115202043p:plain

Extension

Cell・HeaderFooterViewのregisterやdequeueのIdentifierは文字列で扱うが、大抵はクラス名をそのまま使用するので、文字列ではなく、クラスを使って扱えるようにExtensionを定義する。

定義

extension UITableView {

    // func dequeueReusableCell(withIdentifier identifier: String, for indexPath: IndexPath) -> UITableViewCell
    // の代わりに使用する
    func dequeueReusableCell<T: UITableViewCell>(withClass type: T.Type, for indexPath: IndexPath) -> T {
        return self.dequeueReusableCell(withIdentifier: String(describing: type), for: indexPath) as! T
    }

    // func dequeueReusableHeaderFooterView(withIdentifier identifier: String) -> UITableViewHeaderFooterView?
    // の代わりに使用する
    func dequeueReusableHeaderFooterView<T: UITableViewHeaderFooterView>(withClass type: T.Type) -> T {
        return self.dequeueReusableHeaderFooterView(withIdentifier: String(describing: type)) as! T
    }

    // func register(_ nib: UINib?, forCellReuseIdentifier identifier: String)
    // func register(_ cellClass: Swift.AnyClass?, forCellReuseIdentifier identifier: String)
    // の代わりに使用する
    func register(tableViewCellClass cellClass: AnyClass) {
        let className = String(describing: cellClass)
        if UINib.fileExists(nibName: className) {
            self.register(UINib.cachedNib(nibName: className), forCellReuseIdentifier: className)
        } else {
            self.register(cellClass, forCellReuseIdentifier: className)
        }
    }

    // func register(_ nib: UINib?, forHeaderFooterViewReuseIdentifier identifier: String)
    // func register(_ aClass: Swift.AnyClass?, forHeaderFooterViewReuseIdentifier identifier: String)
    // の代わりに使用する
    func register(headerFooterViewClass aClass: AnyClass) {
        let className = String(describing: aClass)
        if UINib.fileExists(nibName: className) {
            self.register(UINib.cachedNib(nibName: className), forHeaderFooterViewReuseIdentifier: className)
        } else {
            self.register(aClass, forHeaderFooterViewReuseIdentifier: className)
        }
    }
}


extension UINib {

    static let nibCache = NSCache<NSString, UINib>()

    static func fileExists(nibName: String) -> Bool {
        return Bundle.main.path(forResource: nibName, ofType: "nib") != nil
    }

    static func cachedNib(nibName: String) -> UINib {
        if let nib = self.nibCache.object(forKey: nibName as NSString) {
            return nib
        } else {
            let nib = UINib(nibName: nibName, bundle: nil)
            self.nibCache.setObject(nib, forKey: nibName as NSString)
            return nib
        }
    }
}

使用時

// Cellの登録
// tableView.register(MyCell.self, forCellReuseIdentifier: "MyCell")
tableView.register(tableViewCellClass: MyCell.self)

// Cellの取得
// let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell", for: indexPath) as! MyCell
let cell = tableView.dequeueReusableCell(withClass: MyCell.self, for: indexPath)

// HeaderFooterViewの登録(MyHeaderView.nibを使用)
// let className = String(describing: MyHeaderView.self)
// tableView.register(UINib(nibName: className, bundle: nil), forCellReuseIdentifier: className)
tableView.register(headerFooterViewClass: MyHeaderView.self)

// HeaderFooterViewの取得
// let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: "MyHeaderView") as! MyHeaderView
let view = tableView.dequeueReusableHeaderFooterView(withClass: MyHeaderView.self)

Fastlaneでplistを更新する

fastlane: 2.3.0

Fastlaneでplistを更新する方法を調べたのでメモ。
update_info_plistというアクションを使う。
fastlane/update_info_plist.rb at master · fastlane/fastlane · GitHub

app_identifierdisplay_nameについてはアクションのパラメータに直接渡せるが、それ以外は更新するロジックをブロックに書いて渡す。

以下はURL Schemeを環境別に更新する例。
正確なパラメータ名はplistファイルをxmlエディタで開いて確認する。

Fastfile

platform :ios do

  lane :update_url_scheme_dev do
    update_url_scheme(scheme: 'Dev')
  end

  lane :update_url_scheme_beta do
    update_url_scheme(scheme: 'Beta')
  end

  private_lane :update_url_scheme do |options|
    update_info_plist(
            plist_path: 'path/to/Info.plist',
            block: lambda { |plist|
              plist['CFBundleURLTypes'].each {|urlType|
                if urlType['CFBundleURLName'] == 'com.example.default-url-identifier'
                  urlSchemes = urlType['CFBundleURLSchemes']
                  urlSchemes.map! {|urlScheme|
                    TARGET_URL_SCHEME = 'myapp'
                    if urlScheme.start_with?(TARGET_URL_SCHEME)
                      "#{TARGET_URL_SCHEME}-#{options[:scheme].downcase}"
                    else
                      urlScheme
                    end
                  }
                end
              }
            }
    )
  end

end

実行

$ fastlane ios update_url_scheme_beta

XcodeをAppStoreを使わずインストールしたときのメモ

Appleアカウントでログインし、以下からダウンロードする。

https://developer.apple.com/download/
https://developer.apple.com/download/more/
xip ファイルを選択する。
リリース直後だとかなり時間がかかる。
Chrome でダウンロードし、展開しようとしたところ
アーカイブXcode_8.2.xip”は壊れているため展開できません。」
と出て失敗した。何回かやっても同様な現象が出てハマった。
一見、ダウンロードが正常に終了したように見えるのだが、どうやら途中で止まり異常終了していたようだ。
Safari でダウンロードしてみたところ、やはり途中で止まってしまったが右上にあるアイコンから
ダウンロード状況を確認でき、再開させて無事ダウンロードすることができた。
f:id:xyk:20161214153025p:plain で、ダウンロードした xip ファイルをそのまま展開しようとすると
GateKeeperによるファイルの検証が始まりこれがまた時間がかかる。
これは xattr コマンドでファイルの拡張属性com.apple.quarantineを削除することでスキップすることができる。

削除前

$ xattr Xcode_8.2.xip
com.apple.metadata:kMDItemDownloadedDate
com.apple.metadata:kMDItemWhereFroms
com.apple.quarantine

com.apple.quarantine属性を削除

$ xattr -d com.apple.quarantine Xcode_8.2.xip

削除後

$ xattr Xcode_8.2.xip
com.apple.metadata:kMDItemDownloadedDate
com.apple.metadata:kMDItemWhereFroms

UIViewに角丸な枠線(破線/点線)を設定する

環境: Swift3

UIViewの角を丸くした枠線を書くには以下のように書けばよい。

let roundView = UIView()
roundView.backgroundColor = .lightGray
roundView.layer.borderColor = UIColor.blue.cgColor
roundView.layer.borderWidth = 3
roundView.layer.cornerRadius = 10
roundView.layer.masksToBounds = true
// roundView.clipsToBounds = true // masksToBounds と同じ

さらに枠線を破線にしたいのでCAShapeLayerを使って以下のように実装した。

以下がプレイグラウンドで確認した完成画像となる。

import UIKit
import PlaygroundSupport

final class DashedBorderAroundView: UIView {
    
    override func layoutSublayers(of layer: CALayer) {
        super.layoutSublayers(of: layer)
        
        if self.dashedBorderLayer.superlayer == nil {
            self.layer.addSublayer(self.dashedBorderLayer)
            // self.layer.insertSublayer(self.dashedBorderLayer, at: 0)
            self.layer.cornerRadius = 10
        }
    }
    
    private lazy var dashedBorderLayer: CAShapeLayer = { [unowned self] in
        
        let rect = self.bounds
        let cornerRadius: CGFloat = 10
        
        let path = UIBezierPath()
        
        path.move(to: CGPoint(x: 0, y: rect.maxY - cornerRadius)) // 1
        path.addLine(to: CGPoint(x: 0, y: cornerRadius)) // 2
        path.addArc(withCenter: CGPoint(x: cornerRadius, y: cornerRadius), // 3
                    radius: cornerRadius,
                    startAngle: CGFloat(M_PI),
                    endAngle: -CGFloat(M_PI_2),
                    clockwise: true)
        path.addLine(to: CGPoint(x: rect.maxX - cornerRadius, y: 0)) // 4
        path.addArc(withCenter: CGPoint(x: rect.maxX - cornerRadius, y: cornerRadius), // 5
                    radius: cornerRadius,
                    startAngle: -CGFloat(M_PI_2),
                    endAngle: 0,
                    clockwise: true)
        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: CGFloat(M_PI_2),
                    clockwise: true)
        path.addLine(to: CGPoint(x: cornerRadius, y: rect.maxY)) // 8
        path.addArc(withCenter: CGPoint(x: cornerRadius, y: rect.maxY - cornerRadius), // 9
                    radius: cornerRadius,
                    startAngle: CGFloat(M_PI_2),
                    endAngle: CGFloat(M_PI),
                    clockwise: true)
        
        let shapeLayer = CAShapeLayer()
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.strokeColor = UIColor.blue.cgColor
        shapeLayer.lineWidth = 3
        shapeLayer.lineDashPattern = [3, 6]
        shapeLayer.lineCap = kCALineJoinRound
        shapeLayer.path = path.cgPath
        
        return shapeLayer
    }()
    
}

let view = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
view.backgroundColor = .white

let dView = DashedBorderAroundView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
dView.backgroundColor = .lightGray
dView.center = view.center
view.addSubview(dView)

PlaygroundPage.current.liveView = view

パスの描画部分がわかりづらいので図で補足説明を追加。

回転方向

パスの順番

アプリサブミット時の輸出コンプライアンスの確認をスキップする

アプリをiTunes Connectでサブミットするときに以下のように毎回「輸出コンプライアンス」についての質問に回答する必要がある。
少しの手間だが面倒なのでこの入力をスキップする方法を調べた。

f:id:xyk:20161128175259p:plain

暗号化機能を含まない場合はInfo.plistITSAppUsesNonExemptEncryptionというキーでNOを設定しておくことで「輸出コンプライアンス」の項目が表示されなくなる。

UIWebViewのリクエストにUserAgentを設定する

環境: Swift3

UIWebViewのリクエストにUserAgentを設定するには、リクエスト前にUserDefaultsのregisterメソッドでキー名UserAgentで値をセットする必要がある。

UserDefaults.standard.register(defaults: ["UserAgent" : "hoge"])

この時、

// これだと設定されない
UserDefaults.standard.set("hoge", forKey: "UserAgent")

のようにUserDefaultsに普通に保存してもダメでregisterメソッドでデフォルト値として設定すること。

// これを使う
open func register(defaults registrationDictionary: [String : Any])

setメソッドとregisterメソッドの違いは以下参照。
xyk.hatenablog.com


追記

ハマリポイントがあったのでメモ。

UIWebViewで使われるUserAgentを取得する方法は以下。

let ua: String? = webView.stringByEvaluatingJavaScript(from: "navigator.userAgent")

取得結果は

Mozilla/5.0 (iPhone; CPU iPhone OS 10_2 like Mac OS X) AppleWebKit/602.3.12 (KHTML, like Gecko) Version/10.0 Mobile/14C92 Safari/602.1

となった。
この結果の末尾に独自の文字列(ここでは例としてhoge)を追加して

Mozilla/5.0 (iPhone; CPU iPhone OS 10_2 like Mac OS X) AppleWebKit/602.3.12 (KHTML, like Gecko) Version/10.0 Mobile/14C92 Safari/602.1 hoge

これを新たなUserAgentとして更新しようとしたがなぜか更新できなかった。
どうやら使用するUIWebViewのインスタンスに対してwebView.stringByEvaluatingJavaScript(from: "navigator.userAgent")を呼び出すとそれ以降、UserAgentを設定しても更新できないようだ。

なので、以下のようにした。

class WebViewController: UIViewController {

   @IBOutlet weak var webView: UIWebView?

    override func loadView() {
        
        self.updateUserAgent()
        super.loadView()
    }

    func updateUserAgent() {
        guard
            let currentUserAgent = UIWebView().stringByEvaluatingJavaScript(from: "navigator.userAgent"),
            !currentUserAgent.contains("hoge")
        else {
            return
        }
        
        let newUserAgent = "\(currentUserAgent) hoge"
        let dic = ["UserAgent" : newUserAgent]
        UserDefaults.standard.register(defaults: dic)
    }

}

実際に使用するself.webViewには触れず、またself.webViewを使用する前に、新たなUIWebViewインスタンスを作って、そこから元のUserAgentを取り出して更新するようにした。

一度更新すればアプリ内では引き継がれるのでapplication:didFinishLaunchingWithOptionsでやってしまっても良いと思う。

UserDefaultsのregisterDefaultsメソッドについて

環境: Swift3

UserDefaultsのregisterDefaultsメソッドについて勘違いしていたのでメモ。

// Swift3 で registerDefaults() から register(defaults: ) に変更になった
open func register(defaults registrationDictionary: [String : Any])

このregisterメソッドを使って登録したDictionary(以降RegistrationDictionaryと呼ぶ)はデフォルト値として使う用でUserDefaultsのデータとしてファイルに書き込まれるわけではない。
あるキーで読み込みした時にそのキーがまだUserDefaultsに存在せず、RegistrationDictionaryに存在すれば、RegistrationDictionaryの値をデフォルト値として返す。
既にキーがUserDefaultsに登録されていた場合、またはその後、そのキーでUserDefaultsに登録された場合は、UserDefaultsの値を返す。

let defaults = UserDefaults.standard

let foo1 = defaults.string(forKey: "Foo")
print("foo1:", foo1) // nil

defaults.register(defaults: ["Foo": "Bar"])

let foo2 = defaults.string(forKey: "Foo")
print("foo2:", foo2) // Optional("Bar")

UserDefaults.standard.set("Hoge", forKey: "Foo")
defaults.synchronize()

let foo3 = defaults.string(forKey: "Foo")
print("foo3:", foo3) // Optional("Hoge")

UserDefaults に保存されているデータをすべて表示する

環境:Swift3

UserDefaults に保存されているデータをすべて表示する

for (key, value) in UserDefaults.standard.dictionaryRepresentation().sorted(by: { $0.0 < $1.0 }) {
    print("- \(key) => \(value)")
}

または

if let appDomain = Bundle.main.bundleIdentifier,
    let dic = UserDefaults.standard.persistentDomain(forName: appDomain) {
            
    for (key, value) in dic.sorted(by: { $0.0 < $1.0 }) {
        print("- \(key) => \(value)")
    }
}

キーの辞書順にソートしてから表示している。

Firebaseでプッシュ通知したが既読数が計測されていなかった件

環境: iOS9

Firebaseのプッシュ通知機能を使い、iOS端末に向けてのプッシュ通知をしたところ、送信は問題なくできたのだが、管理画面から確認できる既読数が0にままだったので原因を調べた。

f:id:xyk:20161104132640p:plain

で原因だが以下ドキュメント

Receive Messages in an iOS App  |  Firebase
https://firebase.google.com/docs/cloud-messaging/ios/receive

Handling messages with method swizzling disabledの項にちゃんと書いてあるのだが Method swizzling をオフにした場合にはFIRMessaging.messaging().appDidReceiveMessage(userInfo)を実装する必要があるが、これを実装していなかった。

func application(application: UIApplication, didReceiveRemoteNotification userInfo: [NSObject : AnyObject], fetchCompletionHandler completionHandler: (UIBackgroundFetchResult) -> Void) {
    // Let FCM know about the message for analytics etc.
    FIRMessaging.messaging().appDidReceiveMessage(userInfo)
    // handle your message
  }

これを実装したところ、既読数も計測されるようになった。

ちなみにプッシュ通知を受信した時に呼ばれるメソッドは以下記事書いたように2種類あるのだが

xyk.hatenablog.com

fetchCompletionHandler 付きのメソッドの方はアプリの状態によらず必ず呼ばれるのでこちらでappDidReceiveMessage(userInfo)を実装すること。

あと、管理画面上に計測結果が反映されるには、少し時間がかかるので注意。
まず送信数の方が数十分〜数時間後に反映され、それからさらに時間をおいて既読数の方が反映される。

また、iOS10ではプッシュ通知周りの実装が少し変わったので以下を参考に今後修正予定。
(以前の実装のままでもiOS10で動作はする)

quickstart-ios/AppDelegate.swift at master · firebase/quickstart-ios
https://github.com/firebase/quickstart-ios/blob/master/messaging/FCMSwift/AppDelegate.swift