Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【iOS】IBDesignableで画像をscaleAspectFitやscaleAspectFillで表示できるUIButtonを作る

はじめに

通信結果から画像を取得し、その画像をAspectFill(もしくはAspectFit)で設定して、その部分を押下すると詳細画面に遷移するというのはアプリ開発においてよくあるパターンだと思います。 例えば、メディア系のアプリでなんらかの特集をくみ、その特集の写真をタップすると詳細記事に遷移するといった場合です。

このような場合の一つの対応方法はUIViewの上にUIImageViewを貼り、その上に同じ大きさのUIButtonを載せるというものです。 ただ、このような実装は単純に手間なだけでなく、ボタン押下時の自然なアニメーション(全体に薄暗い反転がかかるもの)がなくなってしまいがちです。 本記事ではUIButtonの拡張クラスを用意し、UIButtonそのものに上記のような表示の機能を持たせる方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

肝となる部分は、UIButtonの設定項目である、 contentHrozontalAlignmentcontentVierticalAlignmentです。 これらは、それぞれButton内部のコンテンツ(titleLabel、imageView)をUIButton内にどのように配置するかを決定します。 デフォルトではこれらの値はそれぞれ .center.centerになっており、画像のサイズ以上に拡大することはできません。 それぞれを.fill, .fillとすることでボタンのサイズに追随して画像サイズが決定するようになります。

枠いっぱいまで広がった際に、画像部分がどのように表示されるか(短い方に合わせてAspect比を保って画面いっぱい、 長い方に合わせてAspect比を保って画面いっぱい)はUIButtonimageView.contentModeを設定することで指定できます。

あとは通信取得した画像をUIButtonに設定すれば「はじめに」に記載した様なUIを実現できます。 ※余談ですがURLから取得した画像の設定は例えばKingfisherでは button.kf.setImage(with: url, for: .normal)のように実現できます。

import Foundation
import UIKit

@IBDesignable
public final class ImageDesignableButton: UIButton {
    
    // MARK: - IBInspectable
    
    /// 画像部分のCornerRadius
    @IBInspectable
    public var imageCornerRadius: CGFloat {
        set { imageView?.layer.cornerRadius = newValue }
        get { return imageView?.layer.cornerRadius ?? 0.0 }
    }
    
    /// 画像部分のBorderWidth
    @IBInspectable
    public var imageBorderWidth: CGFloat {
        set { imageView?.layer.borderWidth = newValue }
        get { return imageView?.layer.borderWidth ?? 0.0 }
    }
    
    /// 画像部分のBorderColor
    @IBInspectable
    public var imageBorderColor: UIColor {
        set { imageView?.layer.borderColor = newValue.cgColor }
        get { return UIColor(cgColor: imageView?.layer.borderColor ?? UIColor.clear.cgColor) }
    }
    
    /// Button部分と画像部分のpadding
    @IBInspectable
    public var padding: CGFloat = 0 {
        didSet {
            // imageEdgeInsetsを設定してボタンの外枠と画像部分に隙間を作っています
            imageEdgeInsets = UIEdgeInsets(top: padding, left: padding, bottom: padding, right: padding)
        }
    }
    
    // MARK: - Life Cycle
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    public override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
    
    public override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }    
}

// MARK: - Private Function
private extension ImageDesignableButton {
    
    func commonInit() {
        // 画像部分の設定をしています
        // この部分はIBInspectableで外にだしてもいいかもしれません
        // image
        imageView?.contentMode = .scaleAspectFill
        
        // horizontal, verticalそれぞれの設定値を.fillとする
        // ことでコンテンツ部分いっぱいに画像が配置されるようになります
        contentHorizontalAlignment = .fill
        contentVerticalAlignment = .fill
        
        // 画像の外枠を丸くしたり、枠を作ったりする設定です
        // 必要に応じて使ってください
        // image layer
        imageView?.layer.borderColor = imageBorderColor.cgColor
        imageView?.layer.cornerRadius = imageCornerRadius
        imageView?.layer.borderWidth = imageBorderWidth
    }
    
}

なお、paddingに関してtop, left, right, bottomと別々の値を使用したい場合は、privateでUIEdgeInsetsを定義して、 topPadding等をCGFloatで定義し、それぞれのPaddingからInsetsをいじることで似たように設定できます。

まとめ

Paddingをつけながら画像をaspectFillやaspectFitでボタンのサイズに追随して表示するUIButtonのカスタムクラスをご紹介しました。 ぜひ使ってみてください。

参考

【iOS】Place Holder(プレースホルダー)付きのUITextViewを作成する

はじめに

複数行のユーザーインプットを受け付けたいとき、UITextViewをよく使用します。 ユーザーがどのような内容を入力すれば良いかのヒントを与えるためにプレースホルダーの使用を検討することもよくあることだと思います。

ところが、UITextViewにはプレースホルダーが存在しません。したがって、UITextFieldのように placeholder にStringを入れればよい、というように簡単にはいきません。 本記事ではプレースホルダー付きのUITextViewの作成方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

どういう風に実現するのが良いかと考えましたが、最終的にはUITextViewの上にUILabelを置くことにしました。 以下、作成したカスタムクラスのコードを記載します。

概要としては、UILabelをtextContainerのinsetやlineFragmentPaddingを考慮しながら配置、 編集開始と編集終了のイベントをKVOで受け取り、必要に応じてプレースホルダーの表示・非表示を制御しています。 プレースホルダーの文字列はプレースホルダープロパティに設定するのみで表示されるように作成しています。

import Foundation
import UIKit

@IBDesignable
public final class PlaceHolderTextView: UITextView {
    
    // MARK: - IBInspectable
    
    @IBInspectable
    public var cornerRadius: CGFloat {
        set { layer.cornerRadius = newValue }
        get { return layer.cornerRadius }
    }
    
    @IBInspectable
    public var borderColor: UIColor? {
        set { layer.borderColor = newValue?.cgColor }
        get {
            if let cgColor = layer.borderColor {
                return UIColor(cgColor: cgColor)
            } else {
                return nil
            }
        }
    }
    
    @IBInspectable
    public var borderWidth: CGFloat {
        set { layer.borderWidth = newValue }
        get { return layer.borderWidth }
    }
    
    /// プレースホルダー表示用の文字列
    /// 本プロパティの値がプレースホルダーとして表示される
    @IBInspectable
    public var placeHolder: String? {
        didSet {
            placeHolderLabel.text = placeHolder
        }
    }
    
    /// 本来持っているtextプロパティを上書きして、didSetをつけている
    /// このようにすることで、UITextViewの初期値に
    /// textが入っていた際にプレースホルダーを非表示にできる
    public override var text: String! {
        didSet {
            placeHolderLabel.isHidden = !text.isEmpty
            placeHolderLabel.sizeToFit()
        }
    }
    
    private let placeHolderLabel = UILabel(frame: .zero)
    
    // MARK: - Life Cycle
    
    public override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
        
    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override public func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self, name: UITextView.textDidBeginEditingNotification, object: nil)
        NotificationCenter.default.removeObserver(self, name: UITextView.textDidEndEditingNotification, object: nil)
    }
}

private extension PlaceHolderTextView {
    
    private func commonInit() {
        
        // PlaceHolder表示用のラベルをUITextViewに加えConstraintをつける
        layer.masksToBounds = true
        addSubview(placeHolderLabel)
        
        // TextViewのtextと位置が同じになるように、
        // textContainerInsetとlineFragmentPaddingを考慮してConstraintをかける
        let padding = textContainer.lineFragmentPadding
        placeHolderLabel.translatesAutoresizingMaskIntoConstraints = false

        placeHolderLabel.topAnchor.constraint(equalTo: topAnchor
            , constant: textContainerInset.top).isActive = true
        placeHolderLabel.leadingAnchor.constraint(equalTo: leadingAnchor
            , constant: textContainerInset.left + padding).isActive = true
        placeHolderLabel.bottomAnchor.constraint(equalTo:  bottomAnchor
            , constant: textContainerInset.bottom).isActive = true
        placeHolderLabel.trailingAnchor.constraint(equalTo: trailingAnchor
            , constant: textContainerInset.right + padding).isActive = true
        
        let widthConstant = (textContainerInset.left + textContainerInset.right + padding * 2)
        placeHolderLabel.widthAnchor.constraint(equalTo: widthAnchor
            , constant: -widthConstant).isActive = true
        
        // Fontカラーはgrayにしていますが、必要に応じて変更してください
        placeHolderLabel.font = font
        placeHolderLabel.textColor = UIColor.gray
        placeHolderLabel.numberOfLines = 0
        placeHolderLabel.text = placeHolder

        // Observations
        // UITextViewの編集イベントを受け取るためにKVOの設定をしています
        // UITextViewの編集を始めるとプレースホルダーが消え、
        // 編集完了すると、textの中身をみてプレースホルダーを表示するか判断します
        NotificationCenter.default.addObserver(self, selector: #selector(hidePlaceHolder), name: UITextView.textDidBeginEditingNotification, object: nil)
        
        NotificationCenter.default.addObserver(self, selector: #selector(changePlaceholderVisibility), name: UITextView.textDidEndEditingNotification, object: nil)
    }
    
    @objc func hidePlaceHolder() {
        placeHolderLabel.isHidden = true
    }
    
    @objc func changePlaceholderVisibility() {
        placeHolderLabel.isHidden = !text.isEmpty
    }
}

以上で下記のようなプレースホルダー付きのUITextViewが簡単に使用できます。

f:id:Iganin:20190622220151p:plain
PlaceHolderTextView(プレースホルダー表示)
f:id:Iganin:20190622220219p:plain
PlaceHolderTextView(プレースホルダー非表示)

まとめ

プレースホルダー付きのUITextViewの作成方法をご紹介しました。

要件として上がってきた際にはぜひ使用してみてください。

参考

【iOS】単位付きのUITextFieldを作る方法とカスタムクラス

はじめに

ユーザーからの情報の入力の際にTextViewと並んでUITextFieldはよく使われます。 単位付きの入力にしたい場合は、UITextFieldの範囲外にUILabelを設置する、 入力値に単位をつけ、それを特別扱いするようDelegateメソッド内で 対応するといった方法がありますが、今ひとつ実装が面倒なのが正直なところです。

本記事では単位付きのUITextFieldを簡単に作成する方法とカスタムクラスのご紹介をします。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

UITextFieldには、leftView, leftViewMode, rightView, rightViewModeというプロパティがあります。 それぞれUITextFieldの左側のView, 右側に設置するViewおよびそれらをどのように表示するかを指定できます。

なお、ViewModeには以下の設定値があります。

    public enum ViewMode : Int {
        case never // 出さない
        case whileEditing // 編集中のみ出す
        case unlessEditing // 編集中意外に出す
        case always // 常時出す
    }

したがって、leftView, rightViewにUILabelを設定し、適切なViewModeを設定することでUITextFieldに簡単に単位をつけることができます。 なお、leftView, rightViewはUIViewなので、それを継承したUIImageViewなども入れられるはずです。(試してはいません)

カスタムしたUITextField

アプリ作成時に簡単に使用できるように、単位を簡単につけられるUITextFieldを作成しました。

import Foundation
import UIKit

@IBDesignable
public final class WithUnitTextField: UITextField {
    
    @IBInspectable
    public var prefix: String?
    
    @IBInspectable
    public var suffix: String?
    
    public override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    public override func awakeFromNib() {
        super.awakeFromNib()
        commonInit()
    }

    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
      
    public override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
}

private extension WithUnitTextField {

    func commonInit() {
        // ViewMode常時出す設定にしているが必要に応じて変更すること
        // IBInspectableで設定できるようにしてもいいかもしれない
        
        let leftLabel = UILabel(frame: .zero)
        leftLabel.font = font
        leftLabel.text = prefix
        leftLabel.sizeToFit()
        leftView = leftLabel
        leftViewMode = .always
        
        // FontはUITextFieldのFontと同様にしているが、
        // こちらもIBInspectableで設定できるようにしてもいいかもしれない
        
        let rightLabel = UILabel(frame: .zero)
        rightLabel.font = font
        rightLabel.text = suffix
        rightLabel.sizeToFit()
        rightView = rightLabel
        rightViewMode = .always
    }
    
}

例えばですが、下記画像のようなTextFieldがInterface Builder上のみで簡単に作成できます。

f:id:Iganin:20190620003258p:plain

まとめ

単位付きのUITextFieldを作成する方法と、それを活かしたカスタムクラスのコードをご紹介しました。 要件として求められた際にはぜひ選択肢の一つとしてご検討ください。

参考

【iOS】SwiftGenで自動生成されるファイル内のStructやenumのアクセス修飾子をpublicにする方法

はじめに

SwiftGenはR.swift同様にAssetやImage、Storyboardへのアクセスを、安全にしてくれるStructやenumなどの自動生成ツールです。 例えば、UIImageのインスタンスの生成はSwiftGenを使用すると下記のようになり、タイポによる不具合を防ぐことができます。

// 前
let image = UIImage(named: "sample")

// 後
let image = Asset.sample.image

SwiftGenの設定方法等は他にも記事が上がっていますので、そちらをご参照ください。 本記事ではSwiftGenによって自動生成されるstructやenumをpublicアクセスにする方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

なぜpublicにしたいか

SwiftGenによって自動生成されるstructやenumのデフォルトのアクセス修飾子はinternalになっています。

この場合、マルチモジュール構成で画像やColor AssetをUIモジュールに分割していたり、Resourceと判断してそれ用のモジュールに分割していた場合、 そのモジュール外からでは自動生成されたstructやenumにアクセスできません。そこでアクセス修飾子をpublicにしたいというモチベーションが生まれます。

方法

SwiftGenを使用する際には各種設定をswiftgen.ymlファイルに記載しますが、こちらに修正を加えることでpublic accessに変更できます。

xcassets:
  inputs: UIComponent/Assets.xcassets
  outputs:
    templateName: swift4
    output: UIComponent/Generated/DefaultAsset.swift
    params: # ここで設定を行っています
      publicAccess: true

上記の設定内の params -> publicAccess: true で設定を行っています。これでアクセス修飾子を変更できます。

まとめ

SwiftGenを使用した際のアクセス修飾子の変更方法を記載しました。 publicAccess: trueにすれば良いことはわかりましたが、 paramsで設定するという情報になかなかたどり付けなかったため、まとめることにしました。

publicAccess以外にもparamsを使用することでtemplateの設定を上書きし、自動生成されるコードの内容を変えられるようです。ぜひ試してみてください。

参考

【iOS】UITabBarの真ん中のボタンが特殊な見た目・挙動のUIを作成する方法

はじめに

InstagramのようにTabBarの真ん中が通常のTabBarItemではなく、押下することでModalで画面が表示されたり、 シートで選択肢が表示されるようなアプリがあります。また真ん中のボタンのデザインがTabBarからはみ出したりしているようなものもあります。このようなUIを作成する方法に関して記載します。

f:id:Iganin:20190616174521p:plain
Instagramサンプル画像

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

おそらく一番簡単な方法は真ん中にタップ不可能なTabを追加し、その上にボタンを配置する方法です。 まずはTabBarItemの生成部分を見てみます。ここで注意すべき点は以下です。

  • 真ん中にImage, SelectedImage, titleが全てないUITabBarItemを追加する
  • 真ん中のUITabBarItemのisEnableをfalseにする

このようにすることでTabBarの真ん中にボタン配置用のスペースができ、誤って真ん中のタブを選択されるようなことを防げます。

// タブ情報定義部分
    enum TabType: Int, CaseIterable {
        case pickup
        case news
        case empty
        case friends
        case settings

        var data: (title: String, image: UIImage?, selectedImage: UIImage?) {
            switch self {
            case .pickup :
                return (title: "ピックアップ", image: UIImage(named: "ic_pickup"), selectedImage: UIImage(named: "ic_pickup"))
            case .news:
                return (title: "ニュース", image: UIImage(named: "ic_news"), selectedImage: UIImage(named: "ic_news"))
            case .empty:
                return (title: "", image: nil, selectedImage: nil)
            case .friends:
                return (title: "ともだち", image: UIImage(named: "ic_friends"), selectedImage: UIImage(named: "ic_friends"))
            case .settings:
            return (title: "設定", image: UIImage(named: "ic_setting"), selectedImage: UIImage(named: "ic_setting"))
            }
        }
    }


// タブ生成部分
        var addingViewControllers = [UIViewController]()
        
        TabType.allCases.forEach { type in
            var viewController: UIViewController
            switch type {
            case .pickup: viewController = UIViewController(nibName: nil, bundle: nil)
            case .news: viewController = UIViewController(nibName: nil, bundle: nil)
            case .empty: viewController = UIViewController(nibName: nil, bundle: nil)
            case .friends: viewController = UIViewController(nibName: nil, bundle: nil)
            case .settings: viewController = UIViewController(nibName: nil, bundle: nil)
            }
            let navigationController = UINavigationController(rootViewController: viewController)
            let tabBarItem = UITabBarItem(title: type.data.title, image: type.data.image, selectedImage: type.data.selectedImage)
            if case .empty = type { tabBarItem.isEnabled = false } // 真ん中のタブはさわれない
            navigationController.tabBarItem = tabBarItem
            addingViewControllers.append(navigationController)
        }
        setViewControllers(addingViewControllers, animated: false)

次にTabBarの真ん中のボタン生成・配置をみていきます。 特に特別なことはしていなく、Buttonを作成、Propertyとして保持、Viewに追加、viewWillLayoutSubviewsにてレイアウトの調整を行っています。

AutoLayoutで配置の調整を試みましたがうまくいかなかったため、FrameLayoutで行っています。このあたり良い方法をご存知の方がいましたら共有いただけますと嬉しいです。

    // MARK: Property
    private var centerButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
        addCenterButton()
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        centerButton.center = tabBar.center
        centerButton.frame.origin.y = tabBar.frame.origin.y
    }

    func addCenterButton() {
        let centerButton = UIButton(type: .custom)
        centerButton.setBackgroundImage(R.image.white_circle()!, for: .normal)
        centerButton.setImage(R.image.ic_camera()!, for: .normal)
        centerButton.addTarget(self, action: #selector(didTapCenterButton(_:)), for: .touchUpInside)
        centerButton.frame = CGRect(x: 0.0, y: 0.0, width: 50.0, height: 50.0)
        self.centerButton = centerButton
        view.addSubview(centerButton)
    }

    // MARK: Button Action
    @objc func didTapCenterButton(_ sender: UIButton) {
        // 省略
    }

以上で以下のようなTabBarが作成できます。 真ん中のボタンを押下した際の挙動は @objc func didTapCenterButton(_ sender: UIButton) に記載します。

f:id:Iganin:20190616175408p:plain
TabBarサンプル

まとめ

TabBarの真ん中に通常のTabBarとは違う挙動をするボタンを追加する方法を記載しました。 アプリ開発をするなかで要件として出てきた際にはぜひ活用してみてください。 なお、本記事ではコードベースでの生成方法を記載していますが、StoryboardにてTabBarを配置しそちらでレイアウト調整等するのも方法としてありだと思います。

参考

【iOS】RawReperesentableを使用してID間の使用ミスを避ける方法

はじめに

アプリを作成しているとクラスやStructの一意性の判別のためにidを良く使用します。 例えばUserを定義した場合、その一意性を決めるためにUser.idを定義します。 多くの場合はidはStringかIntで定義するかと思います。

idが一つだけならば良いのですが、 jobIdやgroupIdなど複数のIDを扱うようになると、 jobIdとgroupIdにString型を使用していた場合、それらのIdの入れ間違いが発生し得ます。 Idは一意性を担保するため、この間違いは大きな障害に繋がりかねません。

本記事ではこのような問題を発生させない方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2.1
  • Swift 5.0.1

方法

import Foundation

typealias Identifiable = RawRepresentable & Codable & Equatable & Hashable

struct User: Codable {
    let id: ID
    let name: String
    let job: Job
    
    struct ID: Identifiable {
        typealias RawValue = String
        let rawValue: RawValue
    }
}

struct Job: Codable {
    let id: ID
    let name: String
    
    struct ID: Identifiable {
        typealias RawValue = String
        let rawValue: RawValue
    }
}

let userString = """
{
    "id": "11111",
    "name": "Tanaka",
    "job": {
        "id": "01",
        "name": "engineer"
    },
}
"""

let userData = userString.data(using: .utf8)!


let user: User
do {
    user = try JSONDecoder().decode(User.self, from: userData)
    print("User: \(user)")
    print("UserID: \(user.id.rawValue)")
    print("UserJobID: \(user.job.id.rawValue)")
} catch let error {
    fatalError(error.localizedDescription)
}

do {
    let encodedUserData = try JSONEncoder().encode(user)
    let encodedUserString = String(data: encodedUserData, encoding: .utf8)!
    print(encodedUserString)
} catch let error {
    fatalError(error.localizedDescription)
}

上記のようにすることで、 UserのidはUser.ID型、JobのidはJob.ID型となり別々の型となるためIDの間違いがなくなります。

IDをRawReperesentableに準拠させることで、文字列から直接User.ID型にDecodeしたり、User.IDから文字列としてのEncodeが可能になります。 また、ID型をEquatableとHashableに準拠させることで、ID自体の同値判定やDictionaryのkeyとしてID自体を使用することを可能にしています。

IDについては各Struct内に定義するのが良いかと思いますが、 IDとしてStringしか使用しないなどの取り決めがある場合は以下のようにすると定義回数が減らせるのでいいかもしれません。

struct ID<T>: Identifiable {
    let rawValue: String
}

struct User: Codable {
    id: ID<User>
}

またRawRepresentableに準拠する際には以下の書き方もできます。

struct ID: RawRepresentable {
    let rawValue: String
}

RawRepresentableについて

RawRepresentableに関して知識が曖昧だったため学習がてらまとめてみます。

  • rawValueとして指定された型へ、もしくは型から変換できる。
  • Enumの型としてString, Int, 不要小数点型(CGFloat, Floatなど)を指定した場合はCompilerが自動的にRawRepresentableを付与する。
  • OptionSet ProtocolはRawRepresentable Protocolに準拠している。

まとめ

IDの間違いは致命的なバグに繋がり得るため慎重な扱いが必要です。 IDを定義する際の選択肢として本記事の方法がお役に立てば幸いです。

参考

【iOS】表示されているテキストをコピーできるUIを作成する簡単な方法

はじめに

iOSアプリを作成していると文言の表示に通常UILabelを使用します。 ただ、そのままではラベル上の文言のコピーを行うことができず、利便性の面であまり満足できないような場合があります。 以下で文言をコピーできるUIを作成する方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.2
  • Swift 5.0

実装

いくつか方法があるかと思いますが、簡単な方法としてUILabelの代わりにUITextViewを使用する方法があります。 基本的にはUILabelの代わりにUITextViewを設定するだけですが、注意点がいくつかあります。

  • isEditable を falseにする
    • trueのままだとタップで編集できてしまうためそれを防ぎます
  • isScrollEnabled を falseにする
    • このパラメータをfalseにすることで、UILabelのように内部の文言によってUITextViewのサイズが決定するようになります

また、表示する文言の行数を制限する場合下記のように設定します。

textView.textContainer.maximumNumberOfLines = 2;
textView.textContainer.lineBreakMode = .byTruncatingTail

Storyboard上では下記の赤枠の設定を変更します。行数制限についてはcode上で設定を行います。

f:id:Iganin:20190313175528p:plain

実際の画面表示は下記のようになります。該当のText部分をドラッグするとCopy, 検索, Shareの選択肢が表示されます。 ただ、左右のマージンを見ると赤背景の部分と、緑背景の部分でテキスト部分の表示に差があります。 ともに左右10ptのConstraintを親Viewに対してかけていますが、TextViewとUILabelはViewとTextContentとのPaddingなどに差があるためこのような表示の違いが生まれます。

f:id:Iganin:20190407214813p:plain

この部分は、下記の設定によって変更することができます。

  • textContainerInset
  • textContainer.lineFragmentPadding
  • layoutManager.usesFontLeading
        // textView内のInset
        textView.textContainerInset = .zero
        
        // TextView内の左右のPadding
        // defaultの値は5.0
        // contentsに使用できるwidthの計算に使用される
        textView.textContainer.lineFragmentPadding = 0.0
        
        // 通常はleadingがfontに依存して変わる
        // usesFontLeadingをfalseにすることでfontに依存しないようになる
        textView.layoutManager.usesFontLeading = false

ただ、上記の設定もコード上から行う必要があります。そのため、IB上からは設定の状況が確認できません。 そこで下記のように IBDesignableのクラスを作成し、使用することでIB上から確認できるようになります。

import Foundation
import UIKit

@IBDesignable
class UILabelTextView: UITextView {
    
    @IBInspectable
    var numberOfLines: Int = 1 {
        didSet {
            textContainer.maximumNumberOfLines = numberOfLines
        }
    }
    
    // IBInspectableにはEnumであるNSLineBreakModeを使用できなかったためIntを経由しています。
    // より良い方法をご存知の方がいましたら共有いただけますと幸いです。
    @IBInspectable
    var lineBreakModeNumber: Int = 0 {
        didSet {
            textContainer.lineBreakMode = NSLineBreakMode(rawValue: lineBreakModeNumber) ?? .byWordWrapping
        }
    }
    
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        commonInit()
    }
    
    private func commonInit() {
        textContainerInset = .zero
        textContainer.lineFragmentPadding = 0.0
        layoutManager.usesFontLeading = false
    }
}

なお、IBDesignableのクラスを作成する際は、IBを開いた際のビルドスコープを狭めるためにUIパーツをEmbeded Frameworkに分離するのがおすすめです。

まとめ

UILabelのような扱いができるUITextViewを使用して表示文言のコピーができるUIを作成する方法をまとめました。 開発の中で上記要件が出た際はぜひ使用してみてください。

参考

XCodeのコンソールに出力されるCloudFirestoreのindex生成URLがうまく機能しなかった

はじめに

CloudFirestoreを使用してFirestore.firestore()でqueryを生成しデータアクセスをする際に、whereFieldやorderでデータの絞り込みや順番を変更することができます。この際に、指定条件によってはIndexの生成をコンソール経由で下記のように提案されます。

[Firebase/Firestore][I-FST000001] Listen for query at Data failed: The query requires an index. You can create it here: https://console.firebase.google.com/project/{project-name}/database/firestore/indexes?create_index=xxxxxxxxxxxxxxx

通常はyou can create it here:以下のURLの遷移先のFirebase Console上でindexの生成が提案されるのですが、特定のケースでURL先への遷移がうまく行かないことがあったので、事象の原因と解消方法を記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.1
  • Swift4.2
  • FirebaseFirestore 1.0.2

発生事象および条件と解消方法

発生する事象は遷移後の画面がホワイトアウトし、正常に遷移できないというものです。

本事象は複数Googleアカウントを使用している場合に発生する可能性があります。 Index生成アドレスをブラウザに入力し、実際に遷移した後のURLを見るとわかりますが、下記のように/u/0/ がアドレス内に現れます。

https://console.firebase.google.com/u/0/project/{project-name}/database/firestore/indexes?create_index=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

元々のURLに /u/x の指定がないため本事象が発生しているようです。 複数アカウントを使用していると、この部分が本来 /u/1/u/2 などでなければならない場合があり、その場合に遷移に失敗してしまいます。 /u/0/u/{Number} に書き換えれば問題なく遷移できました。({Number}部分は実際に使用しているアカウントのコンソール上から下記の画像のように確認できます)

f:id:Iganin:20190323131906p:plain

Swiftにおける Voidと空Tuple - ()

はじめに

本日、UZUMAKIさん主催の「iOSアプリ設計パターン入門」の勉強会に参加しました。

内容はMVVMアーキテクチャに関してでしたが、議論の中でVoidや()の扱いに関して面白いものがでてきたので備忘もかねて記載します。

環境設定

以下の環境を使用しています。

  • Xcode10.0
  • Swift4.2

Void と 空Tupleについて

Voidは以下の形式で表される「型」です。

public typealias Void = ()

そのため、メソッドの引数等にとることはできません。 その際にはInstanceを入れる必要があります。 多くの場合、このような場合の引数として () を使用すると思います。

let sampleRelay = PublishRelay<Void>()

// このように Voidで型指定されている部分に ( ) を入れる。
sampleRelay.accept(()) 

ただ、 Voidが () の typealiasであることから、以下のように () は型としても機能します。

let a: () = ()

また、 Voidは型であるため以下のようにインスタンス化も可能です。

let a = Void()

したがって、 ()を代入する箇所では Void()の代入でも代替可能です。 ただ、 Void()はできますが、 ()()はできません。 Cannot call value of non-function type '()'というエラーが表示され、コンパイルエラーとなります。

まとめ

以上、取り止めがないですがまとめると下記のようになることがわかりました。

// Void は 型、 ()は型としてもインスタンスとしても機能する

let a: () = ()  // OK
let b = ()  // OK
let c = Void() // OK
let d: Void = () // OK
let e = ()() //  NG
let f: () = ()() //  NG

この辺りの言語仕様は普段あまり意識しませんが、色々触ってみると面白いですね。 個人的にはVoidがインスタンス化可能であることが意外でした。

IBOutletCollectionについて

はじめに

InterfaceBuilderの要素をコードと紐づけるために、IBOutlet、 IBActionが使われます。 ただ、複数の共通要素に対してはIBCollectionを使用すると便利です。 自分用のメモをかねて、IBCollectionに関してまとめます。

環境設定

以下の環境を使用しています。

IB要素の定義

IBOutlet - View要素とコードとの紐付け IBAction - ButtonのAction - Target紐付け IBOutletCollection - 複数View要素とコードとの紐付け

それぞれ、 UINibDeclarationsに以下のように定義されています。

#define IBOutlet
#define IBAction void
#define IBOutletCollection(ClassName)

// IBInspectable, IB_DESIGNABLEも定義されています
#define IBInspectable
#define IB_DESIGNABLE

IB Outlet Collection

IBOutlet, IBActionはStoryboard, Xibから紐づけることがおおいかと思います。  IBOutletCollectionも同様にStoryboard, Xibから紐づける際にIBOutletCollectionを選択して紐づけることが可能です。 またIBOutletCollectionはコード側に記載し、それをStoryboard,Xibとひもづけることもできます。

f:id:Iganin:20190130235856p:plain
IBOutletCollection例

@IBOutlet var buttons: [UIButton]!

IBOutlet, IBActionはweakで紐付けられることが多いですが、 IBOutletCollectionは弱参照では定義できないので注意が必要です。 ※配列による強参照での保持は問題ないのか、という疑問があります。このあたりご存知の方がいらっしゃいましたら共有ください。

格納順

IBOutletCollectionと紐づけた順で格納されていました。 ただ、この辺りはOS versionによって変わる可能性があるので、 tag等で並び替えできるようにしておいた方が安全かもしれません。

まとめ

似たような複数のView要素をIBOutletで接続すると、コードが煩雑になるため、 必要に応じてIBOutletCollectionを使用するとコードがスッキリします。