20190529のSwiftに関する記事は11件です。

[Swift]Alamofire+SwiftyJSONでQiitaの記事の検索結果をTableViewに表示する[API]

はじめに

・Qiitaの記事を検索し、結果を表示するシンプルなアプリです。
・APIを叩いて結果を表示するものの中でも、簡単にできる方法の紹介です。
・一つの機能だけなのでViewControllerにベタ書きしてます。
・比較的初心者向けの記事になります。

完成品

こんな感じでタグで検索をし、それに紐づくQiitaの記事のタイトルを表示します。

Qiita_gif.gif

ライブラリのインストール

今回インストールするのはAlamofireSwiftyJSONになります。
ライブラリの導入方法がわからない方はこちらがわかりやすいかと思います。

StoryBoard

デフォルトで置いてあるViewControllerを消して、TableViewControllerとその上にSearchBarを置きます。
cellのIdentifierの設定を忘れないようにして置きましょう。(今回は"Cell"にしました)

スクリーンショット 2019-05-29 23.22.21.png

ViewController

import UIKit
import SwiftyJSON
import Alamofire


class TableViewController:UITableViewController,UISearchBarDelegate {

    @IBOutlet weak var searchBar: UISearchBar!

    var articles = [[String: AnyObject]]()
    let baseURL = "https://qiita.com/api/v2/items"


//    Get JSON
    func getArticleData(url: String) {

        Alamofire.request(url, method: .get)
            .responseJSON { response in
                if response.result.isSuccess {

                    print("Success! Got the data")
                    let articles : JSON = JSON(response.result.value!)
                        if let article = articles.arrayObject {
                            self.articles = article as! [[String: AnyObject]]
                            print(articles)
                    }
                } else {
                    print("Error: \(String(describing: response.result.error))")
                }

                if self.articles.count > 0 {
                    self.tableView?.reloadData()
                }       
        }        
    }

まず初めに今回使うライブラリのimportを忘れないように行います。
// Get JSONのところで、Alamofireを使ってQiitaのAPIからデータを取得しています。

//    Search function
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {

       getArticleData(url: baseURL + "?page=1&query=tag%3A" + searchBar.text!)
       tableView.reloadData()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        getArticleData(url: baseURL)

}

}

今回はQiitaのタグで検索をかけるようにするため、
getArticleData(url: baseURL + "?page=1&query=tag%3A" + searchBar.text!)
という風にURLに付け加え、新たにJSONを取得するといった風にしています。

// Table cell settings

    extension TableViewController {
        override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
                return articles.count
        }

        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
            let articlePath = articles[indexPath.row]
            cell.textLabel?.text = articlePath["title"] as? String
            return cell
        }

    }

こちらおなじみのTable Cellの設定です。

以上です。
これだけでQiitaの検索が実装できます!

最後に

最後まで読んでくださりありがとうございました。
もしどなたかの為になれれば、幸いです!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swiftの全角文字の活用

全角の変数や関数はほとんど使わないけど、どこかで活用できないかと考えてみた

開発環境
Xcode 10.2.1
Swift 5

Date型をString型にするとき

import Foundation

let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "ja-JP")
formatter.dateFormat = "yyyy/MM/dd"

let date = Date()
print (formatter.string(from: date))    // 2019/05/28

dateFormatをenumで定義する

日時を表すアルファベットをよく忘れるのでenumを使用する

DateFormat
enum DateFormat {
    case yyyyMMdd
    case HHmmss

    var string: String {
        switch self {
        case .yyyyMMdd:
            return "yyyy/MM/dd"
        case .HHmmss:
            return "HH:mm:ss"
        }
    }
}
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = DateFormat.yyyyMMdd.string

let date = Date()
print (formatter.string(from: date))    // 2019/05/28

問題点

  • 区切り文字が定義できない
  • switch文で返す文字列を分岐させなきゃいけない

全角文字の使用

区切り文字を全角にすることで定義できる
文字列は "\(self)" で受け取り、 applyingTransform(transform: , reverse: )を使用し半角にすればOK

enum DateFormat {
    case yyyyMMdd
    case HHmmss

    var string: String {
        return "\(self)".applyingTransform(.fullwidthToHalfwidth, reverse: false)!
    }
}

Extension

Date + toString
extension Date {

    func toString(_ dateFormat: DateFormat,
                  calendar: Calendar = Calendar(identifier: .gregorian),
                  locale: Locale     = Locale(identifier: "en_US_POSIX"),
                  timeZone: TimeZone = TimeZone.current) -> String {
        let formatter = DateFormatter()
        formatter.calendar   = calendar
        formatter.locale     = locale
        formatter.timeZone   = timeZone
        formatter.dateFormat = dateFormat.string

        return formatter.string(from: self)
    }

}
print (Date().toString(.yyyyMMdd))    // 2019/05/28

まとめ

  • 全角記号は意外と使える場面があると思う
  • 全角スペースは使用できないので注意

他に使用できそうなところがあったら教えてください

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WordPressとCloudFunctionsを連携してアプリ内にブログ機能を実装する

WordPressとCloudFunctionsを連携してアプリ内にブログ機能を実装する

PORT Firebase × PostCoffee #2 で登壇したときの内容です。

コーヒーのサブスクアプリ「PostCofee」の開発をしている中川です。
このPostCofeeのアプリはバックエンドがFirebaseのみで作られています。
コーヒーが好きな方はぜひダウンロードしてみてください!

さて、先日のアップデートでストーリーというタブが追加されました。
IMG_5925.PNG
ストーリーはアプリ内でブログを閲覧できる機能です。
アプリ内でWebサイトを閲覧できるというのはごく普通の機能ですが、これをFirebaseで実現する場合どのようにするのかを紹介したいと思います。

WordPressとの連携の流れ

ストーリーで表示しているブログはWordPressで書かれていて、構成はこのようになっています。

ストーリー実装の裏側.png

  1. WordPressで記事を公開、更新する
  2. 上記のタイミングでWordPress側からCloudFunctionsで用意したfunctionを必要なparamを入れて叩いてもらう
  3. CloudFunctionsが叩かれたらparamを受け取ってFireStoreへ保存し、アプリ側へPush通知を送る
  4. アプリ側はCloudFireStoreへデータを取りに行く

CloudFunctionsでやること

まずはCloudFunctionsを実装します。
トリガーは functions.https を使用します。
詳細はこちら HTTP リクエスト経由で関数を呼び出す

また、Push通知もこのCloudFunctionsの中で送るようにします。
今回は全ユーザーを対象に送るためトピック配信を使いました。
トピックにメッセージを送信する

exports.wordPressCreated = functions.region('asia-northeast1').https.onRequest(async (req, res) => {
    // ここでWordPressからのデータを受け取ってFireStoreへ保存します。

    // WordPressからのデータはreq.bodyの中に入っくるので
    // 例) const title = req.body.title
    // このように取得します。

    // FireStoreへの保存が完了したらPush通知を送る
})

Push通知について補足

記事によってPush通知を送る、送らないを分けたい場合、WordPress側から送るデータにBool値を入れると良いです。
Bool値といっても受け取る際にStringの '0', '1' に変換されてしまうようなので判定に注意が必要です。

例) WordPressからのparam
{
   "title" : "ほげほげ",
   "thumbnail" : "https://○○○.png",
   "url" : "https://qiita.com/",
   "isSnedPush" : "1" // Push通知を送るかどうか
}

// isSendPushが '1' のときにPushを送る
if (req.body.isSendPush === '1') {
    // Push通知を送る
}

CloudFunctionsでの実装は以上です。
実装が完了したらデプロイしましょう。

WordPressでやること

WordPress側では記事の公開、更新をフックして上記で実装したCloudFunctionsを叩くようにします。
FireStoreに保存したいデータもここで送るようにします。

叩くURL(CloudFunctions)はFirebaseコンソールの

Functions → ダッシュボード → トリガー

に記載されています。

デバッグについて

最終的には実際にWordPressで記事を公開して確認するのが良いのですが、実装途中で確認したい場合や、WordPressを自分でいじれない場合、直接CloudFunctionsを叩いて確認することができます。

デプロイ済みのCloudFunctionsを叩く方法はいくつかあると思うのですが、今回はPostmanを使用しました。

スクリーンショット 2019-05-29 18.05.42.png
使い方はURLを入力して POST でリクエストするだけです。
今回はWordPress側からparamを受け取るので実際に送るデータをjson形式で指定します。

CloudFunctionsが叩かれたかどうかは

Firebaseコンソールの Functions → ログ

で確認できます。

まとめ

今回はWordPressとCloud Functionsの連携について紹介しました。
WordPressに限らず、他のサービスとの連携もほぼ同じような構成でいけると思いますので、参考になれば幸いです。

ここもっと詳しく知りたいなどありましたら書ける範囲で追記しますのでお気軽にコメントください!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift クロージャのパターン

この記事は何?

前回、クロージャを定数オブジェクトに代入して、呼び出しました。
今回は、パラメータ・返り値の有無で、記述がどう変わるかを確認します。

実行環境

以下の環境で、動作を確認しました。
- Xcode10.2.1
- Swift5.x

ハンズオン

コードを書いてみました。

こんな関数があったとして

ランダムな整数を出力する関数 randomNumber です。

func randomNumber() -> Void {
    print( Int.random(in: 1...Int.max) )
}

randomNumber()    // 1~最大整数のランダムな整数

Void存在しない と言う意味です。
つまり、-> Void返り値がない ってことになります。
これは省略可能な記述なので、普段のコーディングでは記述されません。

クロージャのパターン

引数の有無で、全部で4つのパターンがあります。
いずれにしても {関数の型 in 実装} となっている点を意識してみると、ふーんって感じになれます。

1. 引数なし・返り値なし

パラメータなし・返り値なし
let printRandomNumber = { () -> Void in
    print(Int.random(in: 1...Int.max))
}

2. 引数あり・返り値なし

「出力するランダム整数の最大値」をパラメータとして指定できるようにしています。

パラメータあり・返り値なし
let printRandomNumberWithUpperBounds = { (upperBounds: Int) -> Void in
    print(Int.random(in: 1...upperBounds))
}

3. 引数なし・返り値あり

パラメータなし・返り値あり
let getRandomNumber = { () -> Int in
    return Int.random(in: 1...Int.max)
}

4. 引数あり・返り値あり

「取得するランダム整数の最小値と最大値」をパラメータとして指定できるようにしています。

パラメータあり・返り値あり
let getRandomNumberWithLowerAndUpperBounds = { (lowerBounds: Int, upperBounds: Int) -> Int in
    return Int.random(in: lowerBounds...upperBounds)
}

ファーストクラス関数

オブジェクトに代入される関数のパターンを整理してみました。
が、「そりゃそうだ」みないな内容になってしまいました。
ちなみに...
関数をオブジェクトに代入できる ことは、第一級(ファーストクラス)関数 と言う要件を果たすための1つだそうです。
ファーストクラス関数の要件には、他に...
関数をパラメータとして、関数に渡せる ことなどがあるそうです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift Closureのキホン

この記事は何?

Swiftプログラミングを理解する上で欠かせない Closure(クロージャ) について、キホンから勉強できます。

実行環境

Xcode10.2.1
Swift5.x

ハンズオン

実際にコードを書いて、確認します。

カンタンに言うと

クロージャを使うと、関数をオブジェクトとして扱えるようになります。
関数を 何かしらの目的を果たしてくれる機能 として捉えてみましょう。
そんな表現で、クロージャを使うと...

  • 機能をオブジェクトに代入する
  • 機能をパラメータとして関数に渡す

みたいなことができます。

普通の関数

こんな関数 sum(numbers:) があったとします。
この関数は、整数の配列になっている各要素の合計値を計算してくれます。

func sum(numbers: [Int]) -> Int {
    var total = 0
    for number in numbers { total += number }
    return total
}

パラメータとして [Int] を受けて、Int を返していることに注目しておきましょう。
これは、この関数の型 [Int]->Int です。

実行すると...

sum(numbers: [1, 2, 3])    // 6

1 + 2 + 3 = 6 ですね。

クロージャ

同じ機能をクロージャで表現します。

let sumNumbers = { (numbers: [Int]) -> Int in
    var total = 0
    for number in numbers { total += number }
    return total
}

クロージャを定数オブジェクト sumNumbers に代入してる感じです。
{ 関数の型 in 実装 } の部分がクロージャです。
関数の型だけに注目すると...
(numbers: [Int]) -> Int となっています。
最初の関数 sum(numbers:) と同じ型になっていますね。
機能をオブジェクトに代入する ことができました。
この定数オブジェクトの機能を実行するには...

sumNumbers([1, 2, 3])    // 6

と記述します。
結果は 1 + 2 + 3 = 6 ですね。

クロージャのパターン

この記事で扱ったクロージャは...
引数あり・返り値あり な機能でした。と言うことは、他にも...

  • 引数なし・返り値ありなクロージャ
  • 引数あり・返り値なしなクロージャ
  • 引数なし・返り値なしなクロージャ

があるんでしょうね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS シミュレータ を オレオレ証明書なサーバに接続する手順

iOS シミュレータで localhostに接続する

(今更ATSかよっと思いますがご愛嬌) info.plistにATS対象除外リストに入れるのが普通のやり方だと思いますが、証明書をシミュレータにインストールして、信頼できるサーバとして登録したほうが早そう。ATSの設定をしてもうまく行かなかったので。ただのリンク集です。

手順

そのほか

ATS調査の時に使ったリンク集がこちら。
https://www.gettoby.com/p/rk8t8x16qrms

TOBYというgoogle chrome 拡張。便利ですねコレ。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UITableViewのデリゲートメソッドの呼び出しを別のオブジェクトに転送する方法

UITableViewのデリゲートメソッドの呼び出しを tableView.delegate で指定したオブジェクト以外に転送したかったので、その方法を調べました。 1

要件として、既存の class Delegate: NSObject, UITableViewDelegate が存在する状態で、 Delegate で宣言しているデリゲートメソッドはそのメソッドが呼び出され、宣言していないメソッドは AnotherDelegate に転送してほしいということがありました。

一つの方法として、 Delegate 上で全てのデリゲートメソッドを定義しておき、その各メソッド内部で転送先のオブジェクトの同名メソッドを呼び出すというのを考えたのですが、面倒な上に UITableView の一部のデフォルト動作がデリゲートメソッド自体の有無で切り替わるようになっているらしく、この方法だと定義しなくてもいいメソッドのデフォルト動作がうまく作動しないことがわかりました。

そこで、セレクタの呼び出しをそのまま別のオブジェクトに転送しようと考えその方法を検索しました。すると、 NSProxy を利用した方法などが見つかるのですが、残念ながら NSProxy 中で使われている NSInvocation というクラスがSwiftではunavailableとされており、利用できません。

そこで、以下の方法で実装しました。

前提

  • Xcode 10.2.1
  • Swift 5.0.1
  • iOS 12.2

セレクタについての説明は省略します。

TL;DR

コード

final class Delegate: NSObject, UITableViewDelegate {
    /// 転送先のオブジェクト
    private let proxy: NSObject
    init(with proxy: NSObject) {
        self.proxy = proxy
    }

    override func responds(to aSelector: Selector!) -> Bool {
        // `Delegate` のインスタンスか `proxy` がセレクタを処理できるなら `true` を返す
        return Delegate.instancesRespond(to: aSelector) || proxy.responds(to: aSelector)
    }

    override func forwardingTarget(for aSelector: Selector!) -> Any? {
        // `Delegate` のインスタンスが処理できないセレクタは `proxy` に処理させる
        return proxy
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("Selected: \(indexPath)")
    }
}

final class AnotherDelegate: NSObject, UITableViewDelegate {
    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        print("Deselected: \(indexPath)")
    }
}

tableView.delegate = Delegate(with: AnotherDelegate())

適当にセルをタップした時の出力

Selected: [1, 3]
Deselected: [1, 3]
Selected: [1, 1]
Deselected: [1, 1]
Selected: [2, 0]

1. 既存の Delegate に転送先を保存するためのプロパティを作成する

final class Delegate: NSObject, UITableViewDelegate {
    /// 転送先のオブジェクト
    private let proxy: NSObject
    init(with proxy: NSObject) {
        self.proxy = proxy
    }
    /* ... */
}

まず、転送先のオブジェクトを保存するプロパティ proxy を既存の Delegate に追加しておきます。

2. Delegateresponds(to aSelector: Selector!) を実装する

final class Delegate: NSObject, UITableViewDelegate {
    /* ... */
    override func responds(to aSelector: Selector!) -> Bool {
        // `Delegate` のインスタンスか `proxy` がセレクタを処理できるなら `true` を返す
        return Delegate.instancesRespond(to: aSelector) || proxy.responds(to: aSelector)
    }
    /* ... */
}

responds(to aSelector: Selector!) は、 NSObjectProtocol が定義しているメソッドで、あるオブジェクトが指定したセレクタを処理できるかを返します。

今回の要件の場合、

  1. Delegate のインスタンスが aSelector を処理できる
  2. proxyaSelector を処理できる

のいずれか一方が真なら全体としてセレクタを処理できる事になります。

そこでそれぞれの処理の可能性を、

  1. Delegate.instancesRespond(to: aSelector) (ドキュメント) 2
  2. proxy.responds(to: aSelector) (ドキュメント)

で調べています。

3. DelegateforwardingTarget(for aSelector: Selector!) を実装する

final class Delegate: NSObject, UITableViewDelegate {
    /* ... */
    override func forwardingTarget(for aSelector: Selector!) -> Any? {
        // `Delegate` のインスタンスが処理できないセレクタは `proxy` に処理させる
        return proxy
    }
    /* ... */
}

次に、 Delegate が処理できなかったデリゲートメソッドの呼び出しを proxy に転送する必要があります。

Objective-Cのランタイムでは、あるオブジェクトが実装していないセレクタがそのオブジェクトに通知されると、 forwardingTarget(for aSelector: Selector!) というメソッドが呼び出されます。 (本当はもっと色々呼び出されるのですが、今回関係するのはこのメソッドだけです。)

このメソッドは引数(aSelector)に実装していないセレクタが渡され、戻り値でそのセレクタを処理できる別のオブジェクトを返す必要があります。

今回の要件の場合、 Delegate のインスタンスで処理できないセレクタは proxy が処理できるので、無条件に proxy を返しています。

「処理できないセレクタが呼び出された時にこのメソッドが呼ばれるのであれば、 responds(to aSelector: Selector!) を実装する必要はないのではないか」と思われるかもしれませんが、両方実装しないと正常に動作しません( forwardingTarget(for aSelector: Selector!) が呼ばれない)。おそらく UITableView の内部でデリゲートメソッドを呼び出す際に delegate.responds(to aSelector: Selector!) を呼び出し、 true でない場合にはそのデリゲートメソッドの呼び出し自体を省略しているからだと考えられます。

4. 通常通りデリゲートメソッドを実装する

final class Delegate: NSObject, UITableViewDelegate {
    /* ... */
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("Selected: \(indexPath)")
    }
}

final class AnotherDelegate: NSObject, UITableViewDelegate {
    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        print("Deselected: \(indexPath)")
    }
}

tableView.delegate = Delegate(with: AnotherDelegate())

ここまで実装しておけば、あとは通常通りデリゲートメソッドを実装することで呼び出されます。

TL;DRに書いたコードでは、 DelegatetableView(_:didSelectRowAt:) を実装し、 AnotherDelegatetableView(_:didDeselectRowAt:) を実装していますが、どちらも正常に呼び出されています。

デリゲートメソッドの呼び出しの優先度は、 DelegateAnotherDelegate の順です。もし両方のクラスに同じデリゲートメソッドを定義した場合、 Delegate のものが優先的に呼ばれます。

自分はObjective-Cの理解が全然甘いので、もし何か間違ったことを言っていれば教えてください。

参考文献


  1. タイトルにはUITableViewとありますが、UIKitの他のデリゲートでも同じことができます 

  2. 当たり前ですが、ここで Delegate のインスタンスが aSelector を処理できるかを調べるのに self.responds(to: aSelector)super.responds(to: aSelector) を使うことはできません。前者は無限ループになるし、後者はそもそもスーパークラスに定義されているわけではないので意味が通りません。(多分)この記事のような目的のために NSObject.instancesRespond(to:) というクラスメソッドがあるんだと思います。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

僕のプロフィール

簡単な経歴

1998年生まれ
Geeksalon(プログラミングスクール)Unity編卒業生。
プログラミングSPARTCAMP Swift編、Python編卒業生。
現在はIT企業にて受託案件に携わる。

主な作品

Swiftを用いた開発

タイトル NumberNumber
https://itunes.apple.com/us/app/numbernumber/id1444835578?l=ja&ls=1&mt=8
スクリーンショット 2019-05-28 23.28.13.pngスクリーンショット 2019-05-28 23.28.37.pngスクリーンショット 2019-05-28 23.31.09.pngスクリーンショット 2019-05-28 23.31.28.pngスクリーンショット 2019-05-28 23.30.43.png
様々なアニメーションをを使い、ランダムで出てくる数字に迫力をだし、他の数字ゲームとの差別化を図りました。またUIにもこだわりうユーザーが飽きないようなイタリックでおしゃれな大人でも使えるようなアプリになったと思います。

Hanamaru
https://github.com/matushinn/Hanamaru
スクリーンショット 2019-05-24 19.03.23.pngスクリーンショット 2019-05-24 19.03.46.pngスクリーンショット 2019-05-24 19.06.55.pngスクリーンショット 2019-05-24 19.07.16.pngスクリーンショット 2019-05-24 19.06.17.png

insta
https://github.com/matushinn/InstaApp
スクリーンショット 2019-05-29 11.48.59.pngスクリーンショット 2019-05-29 11.49.13.png
スクリーンショット 2019-05-29 11.57.17.png

Weather
https://github.com/matushinn/WeatherApp
スクリーンショット 2019-05-29 11.29.00.pngスクリーンショット 2019-05-29 11.29.19.png

Unityを用いた開発

MazeMazeMaze
https://github.com/matushinn/OriginalMaze
スクリーンショット 2019-05-29 12.01.29.png スクリーンショット 2019-05-29 12.01.59.png
スクリーンショット 2019-05-29 12.02.48.pngスクリーンショット 2019-05-29 12.03.04.png
ただの迷路ゲームには収まらず、ユーザーが迷路の中にいる相手に見つかったら、かなりのスピードをあげ追いかけてくる機能をつけました。その機能はダイクストラ方という方法を用いました。また実際に森の中で冒険しているような画面に意識して作りました。

大富豪
https://github.com/matushinn/Daifugo
スクリーンショット 2019-05-29 12.23.37.pngスクリーンショット 2019-05-29 12.29.33.png

旗揚げ
https://github.com/matushinn/IntroClass
スクリーンショット 2019-05-29 12.33.38.pngスクリーンショット 2019-05-29 12.33.49.png
クラスの理解にこれを真似して作ってみるのは勉強になっていいと思います。

パズルゲーム
https://github.com/matushinn/2DPiece
スクリーンショット 2019-05-29 12.40.23.pngスクリーンショット 2019-05-29 12.40.48.png

これ以外にも様々なiOS,Androidアプリを開発しました。
仕事の依頼は
shoooe97@gmail.com
までお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[swift]フリック入力ボタン

フリック入力ボタン

フリック入力ボタンを作ったので共有します。

動作イメージ

以下から動作イメージを確認できます。
https://youtu.be/8_mnbM1f-_o

解説コメントなしのコード

以下に解説コメントなしのコードが置いてあります。
https://github.com/sk409/FlickButton-swift/blob/master/FlickButton.swift

解説コメント付きのコード

FlickButton.swift
import UIKit

protocol FlickButtonDelegate {

    // コンポーネントビューが表示される直前に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsWillAppear: [FlickButton.Location: UIView])

    // コンポーネントビューが表示された直後に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsDidAppear: [FlickButton.Location: UIView])

    // フリック入力後に呼び出されます。
    func flickButton(_ flickButton: FlickButton, didFlick activatedView: UIView)

    // コンポーネントビューが非表示になる直前に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsWillDisappear: [FlickButton.Location: UIView])

    // コンポーネントビューが非表示になった直後に呼び出されます。
    func flickButton(_ flickButton: FlickButton, componentsDidDisappear: [FlickButton.Location: UIView])

}

// デフォルト実装では何も処理を行いません。
extension FlickButtonDelegate {
    func flickButton(_ flickButton: FlickButton, componentsWillAppear: [FlickButton.Location: UIView]) {}
    func flickButton(_ flickButton: FlickButton, componentsDidAppear: [FlickButton.Location: UIView]) {}
    func flickButton(_ flickButton: FlickButton, didFlick activatedView: UIView) {}
    func flickButton(_ flickButton: FlickButton, componentsWillDisappear: [FlickButton.Location: UIView]) {}
    func flickButton(_ flickButton: FlickButton, componentsDidDisappear: [FlickButton.Location: UIView]) {}
}

class FlickButton: UIButton {

    // 各コンポーネントビューの位置を区別する
    enum Location {
        case top
        case bottom
        case left
        case right
    }

    //-----------------------------------------------------
    // 各コンポーネントビューです。
    // 設定時にすでにコンポーネントビューが設定されていたら、スーパービューから取り除きます。
    // 設定後にコンポーネントビューを保持する配列にセットします。
    var topView: UIView? {
        willSet {
            guard let topView = topView else {
                return
            }
            topView.removeFromSuperview()
        }
        didSet {
            guard let topView = topView else {
                return
            }
            componentViews[.top] = topView
        }
    }

    var bottomView: UIView? {
        willSet {
            guard let bottomView = bottomView else {
                return
            }
            bottomView.removeFromSuperview()
        }
        didSet {
            guard let bottomView = bottomView else {
                return
            }
            componentViews[.bottom] = bottomView
        }
    }

    var leftView: UIView? {
        willSet {
            guard let leftView = leftView else {
                return
            }
            leftView.removeFromSuperview()
        }
        didSet {
            guard let leftView = leftView else {
                return
            }
            componentViews[.left] = leftView
        }
    }

    var rightView: UIView? {
        willSet {
            guard let rightView = rightView else {
                return
            }
            rightView.removeFromSuperview()
        }
        didSet {
            guard let rightView = rightView else {
                return
            }
            componentViews[.right] = rightView
        }
    }
    //-----------------------------------------------------

    // 各コンポーネントビューと自身のデフォルトカラーです。
    // フリックによってアクティブ状態になっていない場合に背景色に設定されます。
    var defaultColor: UIColor? {
        didSet {
            backgroundColor = defaultColor
        }
    }

    // フリックによってアクティブになった場合に背景色に設定されます。
    var activeColor: UIColor?

    var delegate: FlickButtonDelegate?

    //------------------------------------------------------------------------------
    // 各コンポーネントビューと自身とのマージンを設定できます。
    // ここに設定した数値分離れた場所にコンポーネントビューが配置されます。
    // 例) componentMargins = UIEdgeInsets(top: 2, left: 4, bottom: 6, right: 8)
    //
    //                [topView]
    //                    |
    //                    2
    //                    |
    // [leftView]-4-[FlickButton]-8-[rightView]
    //                    |
    //                    6
    //                    |
    //                [bottomView]
    var componentMargins = UIEdgeInsets.zero
    //------------------------------------------------------------------------------

    // 設定されているコンポーネントビューを保持します。
    private(set) var componentViews = [Location: UIView]()

    // フリック入力によってアクティブになったビューをユーザに分かりやすいように
    // 少しの間表示状態にするために使用します。
    private var timer: Timer?

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupGestureRecognizers()
    }

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

    // 各GestureRecognizerを設定します。
    // UILongPressGestureRecognizer: ロングプレスによって各コンポーネントビューを表示します。
    // UIPanGestureRecognizer: 上下左右のフリック入力の判定に使用します。
    private func setupGestureRecognizers() {
        let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture(_:)))
        longPressGestureRecognizer.delegate = self
        longPressGestureRecognizer.minimumPressDuration = 0.25
        addGestureRecognizer(longPressGestureRecognizer)
        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        panGestureRecognizer.delegate = self
        addGestureRecognizer(panGestureRecognizer)
    }

    // 各コンポーネントビューを表示します。
    // componentMarginsに設定された値を考慮して表示位置を決定します。
    private func addComponentViews() {
        delegate?.flickButton(self, componentsWillAppear: componentViews)
        for (location, componentView) in componentViews {
            componentView.backgroundColor = defaultColor
            addSubview(componentView)
            let safeAreaFrame = safeAreaLayoutGuide.layoutFrame
            switch location {
            case .top:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x,
                    y: safeAreaFrame.origin.y - safeAreaFrame.height - componentMargins.top,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            case .bottom:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x,
                    y: safeAreaFrame.origin.y + safeAreaFrame.height + componentMargins.bottom,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            case .left:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x - safeAreaFrame.width - componentMargins.left,
                    y: safeAreaFrame.origin.y,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            case .right:
                componentView.frame = CGRect(
                    x: safeAreaFrame.origin.x + safeAreaFrame.width + componentMargins.right,
                    y: safeAreaFrame.origin.y,
                    width: safeAreaFrame.width,
                    height: safeAreaFrame.height
                )
            }
        }
        delegate?.flickButton(self, componentsDidAppear: componentViews)
    }

    // 各コンポーネントビューを非表示にします。
    private func removeComponentViews() {
        delegate?.flickButton(self, componentsWillDisappear: componentViews)
        for componentView in componentViews.values {
            componentView.backgroundColor = defaultColor
            componentView.removeFromSuperview()
        }
        delegate?.flickButton(self, componentsDidDisappear: componentViews)
    }

    // フリック入力時に呼び出されます。
    // ユーザの指の位置からどのビューが選択されているかを判定し、背景色を変更します。
    private func handlePanGestureChanged(_ sender: UIPanGestureRecognizer) {
        let location = sender.location(in: self)
        let activatedView = activeView(at: location)
        ([self] + componentViews.values).forEach { componentView in
            componentView.backgroundColor = (activatedView == componentView) ? activeColor : defaultColor
        }
    }

    // フリック入力終了時に呼び出されます。
    // デリゲートにフリック入力が終了したことを通知します。
    // フリック入力によってアクティブになったビューはすぐに非表示にするのではなく、ユーザに分かりやすいように0.2秒間表示した状態を保っています。
    private func handlePanGestureEnded(_ sender: UIPanGestureRecognizer) {

        let location = sender.location(in: self)
        let activatedView = activeView(at: location)
        delegate?.flickButton(self, didFlick: activatedView)

        backgroundColor = defaultColor
        removeComponentViews()
        if activatedView != self {
            addSubview(activatedView)
            activatedView.backgroundColor = activeColor
            timer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(handleRemovingActivatedViewEvent(_:)), userInfo: activatedView, repeats: false)
        }
    }

    // フリック入力時のユーザの指の座標から、選択されているビューを判定し返します。
    // どのビューも選択されていなければFlickButton自身が返されます。
    private func activeView(at location: CGPoint) -> UIView {
        let width = safeAreaLayoutGuide.layoutFrame.width
        let height = safeAreaLayoutGuide.layoutFrame.height
        if let topView = topView, location.y < 0 && ((0 <= location.x && location.x <= width) || abs(location.x) <= abs(location.y)) {
            return topView
        } else if let bottomView = bottomView, height < location.y && ((0 <= location.x && location.x <= width) || abs(location.x) <= abs(location.y)) {
            return bottomView
        } else if let leftView = leftView, location.x < 0 && ((0 <= location.y && location.y <= height) || abs(location.y) <= abs(location.x)) {
            return leftView
        } else if let rightView = rightView, width < location.x && ((0 <= location.y && location.y <= height) || abs(location.y) <= abs(location.x)) {
            return rightView
        }
        return self
    }

    // フリック入力によってアクティブになり少しの間表示状態を保っていたビューを非表示にします。
    @objc
    private func handleRemovingActivatedViewEvent(_ timer: Timer) {
        guard let activatedView = timer.userInfo as? UIView else {
            return
        }
        activatedView.removeFromSuperview()
    }

    // ロングプレスジェスチャーの開始によって各コンポーネントビューを表示します。
    // ロングプレスジェスチャーの終了によって各コンポーネントビューを非表示にします。
    @objc
    private func handleLongPressGesture(_ sender: UILongPressGestureRecognizer) {
        if sender.state == .began {
            addComponentViews()
        } else if sender.state == .ended {
            removeComponentViews()
        }
    }

    // パンジェスチャーのコールバックです。
    @objc
    private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
        if sender.state == .began {
            backgroundColor = activeColor
            addComponentViews()
        } else if sender.state == .changed {
            handlePanGestureChanged(sender)
        } else if sender.state == .ended {
            handlePanGestureEnded(sender)
        }
    }

}

extension FlickButton: UIGestureRecognizerDelegate {

    // UILongPressGestureRecognizerとUIPanGestureRecognizerが同時に認識されるようにしています。
    // 以下の記事を参考にしました。
    // https://qiita.com/ruwatana/items/16997b1b416512c20fb6
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }

}


使用方法

上記の動作イメージ動画で使用してるコードを掲載します。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .lightGray

        let flickButton = FlickButton()
        view.addSubview(flickButton)
        flickButton.delegate = self
        flickButton.defaultColor = .darkGray
        flickButton.activeColor = .cyan
        flickButton.setTitle("CENTER", for: .normal)
        flickButton.setTitleColor(.white, for: .normal)
        flickButton.titleLabel?.font = .boldSystemFont(ofSize: 18)
        flickButton.addTarget(self, action: #selector(handleFlickButtonTouchUpInsideEvent(_:)), for: .touchUpInside)
        flickButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            flickButton.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
            flickButton.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
            flickButton.widthAnchor.constraint(equalToConstant: 128),
            flickButton.heightAnchor.constraint(equalTo: flickButton.widthAnchor),
            ])

        let topView = UILabel()
        flickButton.topView = topView
        topView.text = "TOP"
        topView.textColor = .white
        topView.textAlignment = .center
        topView.font = .boldSystemFont(ofSize: 18)
        let bottomView = UILabel()
        flickButton.bottomView = bottomView
        bottomView.text = "BOTTOM"
        bottomView.textColor = .white
        bottomView.textAlignment = .center
        bottomView.font = .boldSystemFont(ofSize: 18)
        let leftView = UILabel()
        flickButton.leftView = leftView
        leftView.text = "LEFT"
        leftView.textColor = .white
        leftView.textAlignment = .center
        leftView.font = .boldSystemFont(ofSize: 18)
        let rightView = UILabel()
        flickButton.rightView = rightView
        rightView.text = "RIGHT"
        rightView.textColor = .white
        rightView.textAlignment = .center
        rightView.font = .boldSystemFont(ofSize: 18)

        view.addSubview(label)
        label.backgroundColor = .darkGray
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 24)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            label.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            label.heightAnchor.constraint(equalToConstant: 64),
            ])
    }

    @objc
    private func handleFlickButtonTouchUpInsideEvent(_ sender: FlickButton) {
        label.text = sender.title(for: .normal)
    }

}

extension ViewController: FlickButtonDelegate {

    func flickButton(_ flickButton: FlickButton, didFlick activatedView: UIView) {
        if let flickButton = activatedView as? FlickButton {
            label.text = flickButton.title(for: .normal)
        } else {
            label.text = (activatedView as? UILabel)?.text
        }
    }

}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「」内の文字が省略されるUILabelを作る

ときどき、文字列の中央を省略するのではなく、「」内の文字を省略して表示してほしいという要件が来ることがあります。正攻法でやるとなかなか難しそうなので、こんな方法を考えました。

サンプルコードはこちら。

  • 文頭「 中の文字列 」文末 のように、3つの UILabel に分割し、Stack View等でまとめる
  • 省略したい中央の UILabelContent Compression Resistance Priority を、ほかの2つの UILabel より小さいな値にする

コード上では特別することはありませんが、文全体が動的に変わるような場合は適宜分割し、それぞれのラベルに振り分ける必要があります。

ちなみにもし、一行ではなく複数行でこれをやりたいというケースだった場合...どうしたらいいんでしょうね(笑)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【学習記録38】2019/5/29(水)

学習時間

4.0H

使用教材

学習分野

コメント

・アプリ制作
学習開始からの期間:39日目
今日までの合計時間:124.0H

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む