2021年10月3日日曜日

UITabBarAppearanceを使って、TabBarの配色をセットする

iOS13以降のTabBarのカスタマイズです。
今回の記事では、次のようにTabBarをカスタマイズしました。

1. UITabBarを継承した、カスタムViewを作成する
2. TabBarItemの配色をUITabBarItemAppearanceで作成する
3. UITabBarAppearanceを生成する
4. UITabBarにセットする
5. UITabBarViewControllerにて、カスタムViewを使う


UITabBarを継承した、カスタムViewは次になります。

final class MyUITabBar: UITabBar {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    private enum Const{
        static let backgroundColor: UIColor = .systemGroupedBackground
        static let tintColor: UIColor = .gray
        static let selectedColor: UIColor = .black
    }
    
    private func setupView() {
        let tabBarItemAppearance = setupTabBarItemAppearance()
        let appearance = UITabBarAppearance()
        appearance.configureWithOpaqueBackground()
        appearance.backgroundColor = Const.backgroundColor
        appearance.stackedLayoutAppearance = tabBarItemAppearance
        appearance.inlineLayoutAppearance = tabBarItemAppearance
        appearance.compactInlineLayoutAppearance = tabBarItemAppearance

        standardAppearance = appearance
        // iOS15: we need to set
        scrollEdgeAppearance = appearance
    }

    private func setupTabBarItemAppearance() -> UITabBarItemAppearance {
        let tabBarItemAppearance = UITabBarItemAppearance()
        // for normal
        tabBarItemAppearance.normal.iconColor = Const.tintColor
        tabBarItemAppearance.normal.titleTextAttributes = [NSAttributedString.Key.foregroundColor: Const.tintColor]
        
        // for selected
        tabBarItemAppearance.selected.iconColor = Const.selectedColor
        tabBarItemAppearance.selected.titleTextAttributes = [NSAttributedString.Key.foregroundColor: Const.selectedColor]
        
        return tabBarItemAppearance
    }
}

UITabBarControllerの子となる各ViewControllerでは、次のようにtabBarItemを指定するのみです。
tabBarItemと配色をするAppearanceをコード上で分け、責務が分離できるので非常に見やすいコードとなりました。

final class FirstViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTabBar()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
    }

    private func setupTabBar() {
        tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: "house"), selectedImage: UIImage(systemName: "house"))
    }
}

final class SecondViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTabBar()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
    }

    private func setupTabBar() {
        tabBarItem = UITabBarItem(title: "Trash", image: UIImage(systemName: "trash"), selectedImage: UIImage(systemName: "trash"))
    }

}

2021年9月26日日曜日

リンクタップ可能でかつ選択不可なUITextViewを作る

次なような、UITextViewを作成することにしました。

1. リンクタップは可能である、その他のテキストは操作無反応とする
2. ダブルタップによるテキスト選択動作は不可である
3. ロングタップは不可である


UITextViewのカスタムViewを作成する

条件1から3まで満たすようなカスタムViewを作ります。
この方法は、Stackoverflowに書かれているものを参考にしました。
UITextView: Disable selection, allow links


class UnselectableTextView: UITextView {
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        guard let position = closestPosition(to: point) else { return false }
        guard let range = tokenizer.rangeEnclosingPosition(
                position,
                with: .character,
                inDirection: UITextDirection(rawValue: UITextLayoutDirection.left.rawValue)
              ) else { return false }
        let startIndex = offset(from: beginningOfDocument, to: range.start)
        return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil
    }

    override func becomeFirstResponder() -> Bool {
        return false
    }
}


override func point() にて、タップした位置のテキストがリンクであるかどうか判定し、override func becomeFirstResponder() にてダブルタップとロングタップを抑制しています。
ViewControllerに実装するときは、次のようになります。


class ViewController: UIViewController {

    @IBOutlet weak var textView: UnselectableTextView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
    }

    private func setupViews() {
        textView.delegate = self
        textView.isEditable = false
        textView.isSelectable = true
        textView.backgroundColor = .clear

        let attributedString = NSMutableAttributedString(string: "this textview has a link test.")
        attributedString.addAttribute(.link, value: "https://www.google.com", range: NSRange(location: 20, length: 4))
        textView.attributedText = attributedString
    }
}

extension UIViewController: UITextViewDelegate {
    public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        print(URL.absoluteString)
        return false
    }
}


ロングタップをハンドリングする

上記で作成したUnselectableTextViewに、独自の UILongPressGestureRecognizer を組み込もうとしたが断念。
UITextViewの GestureRecognizer とConflictしてしまい、良い感じで実装できずでした。
代替案として、UnselectableTextViewの後ろにUIViewを配置して、このUIViewにてロングタップをハンドリングさせます。


class ViewController: UIViewController {
    private func setupGesture() {
        let longPressGesture = UILongPressGestureRecognizer(
            target: self,
            action: #selector(ViewController.longPress(_:))
        )
        longPressGesture.delegate = self
        backgroundView.addGestureRecognizer(longPressGesture)
    }
}

extension UIViewController: UIGestureRecognizerDelegate {
    @objc func longPress(_ sender: UILongPressGestureRecognizer) {
        print(sender.state.rawValue.description)
    }
}

2021年7月5日月曜日

画面幅より短い場合はCenterさせるUIScrollViewを作成する

次のようなViewを条件を満たすViewを作りたくなったので実装してみました。
  • 画面幅より短い場合は、Center表示させる
  • 画面幅より長い場合は、左寄せで表示し横スクロール可能である

完成イメージは次のような感じです。

Viewの構成

Viewの構成は、UIScrollViewの中にコンテンツ用のUIViewを入れるのみです。
    private let scrollView = UIScrollView()
    private let contentView = UIView()


    private func setupViews() {
        scrollView.addSubview(contentView)
        addSubview(scrollView)
    }

実際に表示させたいViewは、次のようにfunc setup(_)メソッドを使って挿入させることを想定しています。

    func setup(_ view: UIView) {
        contentView.addSubview(view)
        view.snp.makeConstraints {
            $0.edges.equalTo(contentView)
        }
    }



制約をセットする

scrollViewの制約は、通常通り親Viewに対して同じ大きさになるよう制約をセットします。
contentViewの制約は工夫が必要で、親Viewに対して横Centerの指定とleftに対してgreaterThanOrEqualToを指定します。
この2つの制約によりCenter表示が可能になります。
    private func setupConstraint() {
        scrollView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        contentView.snp.makeConstraints {
            $0.height.equalTo(scrollView.frameLayoutGuide)
            // 横幅より小さい場合は、Center
            // 横幅より大きい場合は、通常の左寄せ + スクロール
            $0.top.right.bottom.equalTo(scrollView.contentLayoutGuide)
            $0.left.equalTo(scrollView.contentLayoutGuide).priority(.low)
            $0.left.greaterThanOrEqualTo(scrollView.contentLayoutGuide)
            $0.centerX.greaterThanOrEqualToSuperview()
        }
    }


ScrollのContent領域が定まらないというLayoutのwarningが発生したため、次の制約を追加します。
$0.left.equalTo(scrollView.contentLayoutGuide).priority(.low)



完成形のコード

上記の全てを組み合わせると、次のようなコードになります。
final class HorizontalScrollView: UIView {
    private let scrollView = UIScrollView()
    private let contentView = UIView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViews()
        setupConstraint()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupViews()
        setupConstraint()
    }

    private func setupViews() {
        scrollView.addSubview(contentView)
        addSubview(scrollView)
    }

    private func setupConstraint() {
        scrollView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        contentView.snp.makeConstraints {
            $0.height.equalTo(scrollView.frameLayoutGuide)
            // aligin center when content width < frame width
            // aligin left when content width > frame width
            $0.top.right.bottom.equalTo(scrollView.contentLayoutGuide)
            $0.left.equalTo(scrollView.contentLayoutGuide).priority(.low)
            $0.left.greaterThanOrEqualTo(scrollView.contentLayoutGuide)
            $0.centerX.greaterThanOrEqualToSuperview()
        }
    }

    func setup(_ view: UIView) {
        contentView.addSubview(view)
        view.snp.makeConstraints {
            $0.edges.equalTo(contentView)
        }
    }
}