20200820のiOSに関する記事は8件です。

プライマリ言語を英語にして配信する際の勘所【AppStore】

この記事は

リリース作業などはしばらくやっていないと忘れますね。
AppStoreConnectでプライマリ言語を英語にするか日本語にするか悩んでる方向けに書きました。

プライマリ言語

AppStoreは国別にあり、デフォルトでは全ての国に対してアプリを配信することができます。
プライマリ言語設定を英語(アメリカ)にしてリリースすると、ローカライズされていない国に関してはプライマリ言語設定が優先されます。

ローカライズされていない国に関してはプライマリー言語(英語)が優先されるということは、北米や、英語のアジア圏の各国、イギリス、インドなどの英語を利用している国々対してアプリをリーチすることができます。

ではプライマリ言語設定を日本語設定にしてリリースするとどうなるかというと。
日本以外の諸外国でかつローカライズをしていない場合、日本語のApp名、概要、スクリーンショット、プロモーションテキスト、キーワードなどが適応されるので、日本語で検索しないとアプリが引っかからないため、世界に対してアプリをリーチすることができなくなります。

結論

主要言語設定を 英語(アメリカ) にして 日本語 はローカライズとしてリリースする。
日本を除き他の所外国に関しては英語が優先されるので、App名、キーワード、概要などが英語で表示されます。
仮にアプリをいつか中国エリアにも対応したい場合は、中国語(簡体or繁体)のローカライズを追加というように国別に言語を追加していくことが理想です。

プライマリ言語を英語にした際に必要なもの

アプリ

  • アプリ内で使用する文言
  • アプリ内で使用する素材

AppStore

  • アプリ名
  • 概要
  • キーワード
  • サブタイトル
  • プロモーション用テキスト
  • スクリーンショット

必ずしもローカライズする必要ない?

  • プライバシーポリシー
  • サポートURL

プライバシーポリシーページは日本語・英語で記載があるといい思います。
サポートページは英語対応していたら海外のユーザーからフィードバック等があると思います。

時間的に制約がある場合は、AppStore用だけでも用意すればいいのかなと思います。

参考

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

UITableviewをスクロールした際、Section headerを固定しない

自分用メモ

SwiftでUItableviewを使っている時、スクロールしたときにHeaderが画面に残るのをFloatにする。

コード

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let sectionHeaderHeight:CGFloat  = 40;
        if (scrollView.contentOffset.y <= sectionHeaderHeight && scrollView.contentOffset.y >= 0) {
            scrollView.contentInset = UIEdgeInsets(top: -scrollView.contentOffset.y, left: 0, bottom: 0, right: 0);
        } else if (scrollView.contentOffset.y >= sectionHeaderHeight) {
            scrollView.contentInset = UIEdgeInsets(top: -sectionHeaderHeight, left: 0, bottom: 0, right: 0);
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【SwiftUI】NavigationView タイトルカスタマイズ方法

タイトルとアクション等の設定

NavigationView に属性をつけるのではなく中身につけることに注意。

NavigationView {
    VStack {
        Text("How to customize view")
    }
    .navigationBarTitle(Text("Hello"))
    .navigationBarItems(trailing: Button("Done", action: {
        // Action
        // 左に作るときは leading: にする
    }))
    // .inline .automatic .large
    .navigationBarTitleDisplayMode(.automatic)
    .navigationBarBackButtonHidden(true)
}

タイトルのフォント/背景色の変え方

appearance()はあまり使わない方がいいという意見もありますが、これがとりあえず一番やりやすいかと思います。
UIViewController の属性を引き継いでいるので、中身はUIFont UIColor で設定します。

struct MyView: View {
    init() {
         // 背景を透明にする
         UINavigationBarAppearance().configureWithTransparentBackground()

         // コンパクトなナビゲーションバーの時の背景色を変える
         UINavigationBar.appearance().barTintColor = .systemRed

         // コンパクトなナビゲーションバーのタイトル属性
         UINavigationBar.appearance().titleTextAttributes = [
             .font: UIFont.systemFont(ofSize: 10)
             .foregroundColor: .systemBlue!,
         ]
         UINavigationBar.appearance().largeTitleTextAttributes = [
             .font: UIFont.systemFont(ofSize: 20)
             .foregroundColor: .systemBlue!,
         ]
     }
     var body: some View {
          // Navigation View
     }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【今日からできる】SwiftUI/Combineのユニットテスト

こちらの記事は、YUMEMI.swift #9 ~テストと自動化~で発表した内容をもとに書いたものです。

サンプルプロジェクトはこちらにあります。

SwiftUIには公式のテスト用APIが用意されていない

UIKitなら例えば以下のようにテストを行うことができます。

let viewController = ViewController()

// viewDidLoadを呼ぶためのメソッド
viewController.loadViewIfNeeded()

// subviewsからボタンを検索
let button = viewController
    .view
    .subviews
    .first(where: { $0 is UIButton })

// 存在確認
XCTAssertNotNil(button)

// タップイベントを発火
button!.sendActions(for: .touchUpInside)

SwiftUIのViewがユニットテストできないなんて誰が言ったの?

検索してみると……

Who said we cannot unit test SwiftUI views?1

という記事がヒットしました。どうやらこの記事を書いた人がViewInspectorというテスト用のフレームワークを作成してくれているそうです。

現状では、SwiftUIのユニットテストができる唯一無二のフレームワークで、公式のフレームワークが出るまではスタンダードになっていきそうな予感がします。

ViewInspector

こんな感じのAPIが用意されています。

// ボタンタップを実行
let button = try view
    .inspect()
    .hStack()
    .button(1)
try button.tap()

// onAppearをシミュレート
let list = try view
    .inspect()
    .list()
try list[5]
    .view(RowItemView.self)
    .callOnAppear()

実際に使ってみる

サンプルのプロジェクトはよくある、GitHubのリポジトリを検索してくるやつです。

こんな感じのUIで、Listの下に"Search More Swift"というButtonが表示されています。これが表示されているかどうかをユニットテストで確認していきます。

準備:DIのためにprotocol経由で参照を持たせる

テストしやすいようにViewにprotocol経由でViewModelを持たせます。

// View
struct SearchRepositoriesView<ViewModel: ViewModelProtocol>: View {
    // ObservableObject protocolがassociatedtypeを持っているため
    // protocolのまま型指定できないので、Generic Typeを利用する
    @ObservedObject private var viewModel: ViewModel
    ...
}

// ViewModel
protocol ViewModelProtocol: ObservableObject {
    var lastQuery: String? { get }
    var state: ViewModelState { get }
    // Binding<Bool>として使うのでsetが必要
    var shouldShowErrorAlert: Bool { get set }
    var repositoryList: [GitHubRepo] { get }
    var moreSearchResultsExist: Bool { get }
    func send(event: SearchRepositoriesViewModelEvent)
}

ViewInspectorを使って要素を取得する

注意点として、「子要素を複数持つ可能性のある要素」から子要素を取得するときには、子要素(例えばVStack)に対応したメソッド(vStack)に、そのindex(親要素の中で何番目にあるか)を渡す必要があります。

テストコード
// 対象のViewにInspectable protocolを追加
extension SearchRepositoriesView: Inspectable {}
...

// ViewModelのスタブを作成
stubViewModel = SearchRepositoriesViewModelStub()

// 依存性注入
view = SearchRepositoriesView(viewModel: stubViewModel)

stubViewModel.state = .ready
stubViewModel.repositoryList = ...
stubViewModel.moreResultsExist = true

let text = try view!.inspect()
    .zStack()
    .vStack(0) // Zstackのindex:0の位置
    .list(1)   // Vstackのindex:1の位置
    .button(1) // Listのindex:1の位置
    .text()

XCTAssertEqual(try text.string(), "Search More ")
Viewの構造
ZStack {
    VStack { // index: 0
        SearchBar(...) // index: 0
        List { // index: 1
            ForEach(...) { ... } // index: 0
            if ... {
                Text("No results found...")
            } else if self.viewModel.moreResultsExist {
                Button(action: { ... }) { // index: 1
                    if self.isLoading {
                        ActivityIndicator()
                    } else {
                        (Text("Search More ") + // Target!!!
                            Text(self.lastQuery)
                                .font(.headline)
                                .bold()
                                .italic()
                            )
                    }
                }
            }
        }
    }
    if isLoading && isListEmpty {
        ActivityIndicatorWithRectangle()
    }
}

カスタムViewの存在確認

今度はTextではなく、カスタムViewが表示されていることを確認します。
先ほどの"Search More Swift" Buttonをタップすると、インディケーターが周りだします。

テストコード
stubViewModel.state = .loading
stubViewModel.repositoryList = ...
stubViewModel.moreResultsExist = true

let button = try view!.inspect()
    .zStack()
    .vStack(0) // Zstackのindex:0の位置
    .list(1)   // Vstackのindex:1の位置
    .button(1) // Listのindex:1の位置

// カスタムViewを取り出す
// viewメソッドにstructのselfを渡したあと、actualViewメソッドで
// ActivityIndicatorのインスタンスが取得できる
XCTAssertNoThrow(
  try button
      .view(ActivityIndicator.self)
      .actualView()
)

// (おまけ)Textが入っていないことも確認
XCTAssertThrowsError(try button.text())
ActivityIndicator
struct ActivityIndicator: UIViewRepresentable {
    func makeUIView(
        context: Context
    ) -> UIActivityIndicatorView {
        let indicator
            = UIActivityIndicatorView()
        indicator.startAnimating()
        return indicator
    }
    ...
}
Buttonの内部(再掲)
Button(action: { ... }) {
    if self.isLoading {
        ActivityIndicator() // Target!
    } else {
        (Text("Search More ") +
            Text(self.lastQuery)
                .font(.headline)
                .bold()
                .italic()
            )
    }
}

SwiftUIのユニットテスト → やりやすい

SwiftUIではViewが状態の関数であることが強制されるおかげで、何をテストすべきかがわかりやすいため、ユニットテストが書きやすいです。

Combineのユニットテスト

CombineのユーティリティフレームワークであるEntwineを使います。

テスト補助用のクラスとしてTestSchedulerが用意されているので、これを使ってViewModelのテストをやってみます。

ViewModel
protocol ViewModelProtocol: ObservableObject {
    // 最後に使ったクエリを取得できる
    var lastQuery: String? { get }
    // View側からイベント通知をするためのメソッド
    func send(event: SearchRepositoriesViewModelEvent)
    ...
}

class ViewModel: ViewModelProtocol {
    // サーバーとの通信を行うModel
    private let reposModel: GitHubReposModelProtocol

    // 最後に使ったクエリは@Publishedをつけて、SwiftUI Viewの監視対象
    @Published private(set) var lastQuery: String?
    ...
}

スケジューラーにイベントの通知をスケジューリングしていきます。

stubModel = GitHubReposModelStub()
viewModel = SearchRepositoriesViewModel(model: stubModel)

let scheduler = TestScheduler.init(initialClock: 0)
// t = 100に "Swift" という queryを発行
scheduler.schedule(after: 100) {
    self.viewModel.send(event: .didSearchButtonClicked(query: "Swift"))
}
// t = 200に "Python" という queryを発行
scheduler.schedule(after: 200) {
    self.viewModel.send(event: .didSearchButtonClicked(query: "Python"))
}

テスト用のsubscriberを作成して、テスト対象であるviewModel.$lastQueryにsubscribeします。

// テスト用のsubscriberを作成
let subscriber = scheduler.createTestableSubscriber(String?.self, Never.self)
viewModel.$lastQuery.subscribe(subscriber)

スケジューラーを起動すると、テスト用のsubscriberがイベントを受け取り、TestSequenceとして記録してくれます。

// スケジューラを起動
scheduler.resume()

let expected: TestSequence<String?, Never> = [
    (000, .subscription),
    (000, .input(nil)),
    (100, .input("Swift")),
    (200, .input("Python")),
]
// 受け取ったイベントと期待されるイベントを比較
XCTAssertEqual(subscriber.recordedOutput, expected)

おわりに

意外と簡単にユニットテストが書けたのではないでしょうか?
どんどんテストを書いていきましょう!

参考

UIViewControllerをXCTestでUnitテストする
Who said we cannot unit test SwiftUI views?
ViewInspector
Entwine


  1. 階層化されたSwiftUIのViewから、どうやって中身を取り出していくかという苦労が綴られていて面白いです。 

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

JenkinsでiOSビルドする際に、プロビジョニングファイルを指定する

はじめに

Jenkinsで開発版をビルドしていて、いざAppStore向けにもビルドするとなったとき、プロビジョニングプロファイルを新しく作りました。

開発版ビルドはプロビジョニングプロファイルを自動指定だったので、このままだと開発版ビルドがどのプロビジョニングプロファイルを使うか分からないので明示的に指定する必要が出てきました。

秒で終わる内容ですが、その時の対応を備忘録としてメモしておきます。

環境

MacBook Pro(16-inch,2019)
macOS Catalina 10.15.2
XCode 11.3

Jenkinsジョブ設定済みで、バッチビルドのセットアップは済んでいる状態とします。
ビルドしたのはUnityで作ったプロジェクトです。

対応

ビルド時に使うplistにプロビジョニングプロファイルのIDを指定する

plistの内に以下の設定を追加します。

exportOption.plist
    <key>provisioningProfiles</key>
    <dict>
        <key>プロビジョニングファイルに紐付いたBundleID(ex:com.xxx.xxx)</key>
        <string>プロビジョニングファイルのName(ex:MyApp_Distribution)</string>
    </dict>

XCodeにプロビジョニングプロファイルをダウンロードする

XCodeを開いて、プロビジョニングプロファイルをダウンロードします。

メニューバー→Xcode→Preferencesで設定を開きます。
次にAccountsタブで、指定したプロビジョニングプロファイルに含まれているチームやユーザを指定して「Download Manual Profiles」をクリックします。

おわり

ダウンロードが終わるまで待てば完了です。
ジョブを動かして正しくビルドされるか確認します。

補足

この設定が無い場合は、自動でプロビジョニングプロファイルを選択してビルドします(おそらくXCode内にダウンロードしてあり、ビルド対象プロジェクトで使えるものが選択される?)

XCodeのプロビジョニングプロファイルが古い時は、「~/Library/MobileDevice/Provisioning Profiles/」ディレクトリの中のプロビジョニングプロファイルを削除してから、上記のダウンロード操作をします。

参考リンク

https://qiita.com/taisuke_h/items/e37d96c96a811b630c0c

plistの各種設定の確認に。

https://qiita.com/roworks/items/7ef12acabf9679561d84

plistのxcodeビルド周り全般の参考に。

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

長押し続けている間繰り返し処理をするLongPressGestureRecognizer

UILongPressGestureRecognizer + Timerで

押し続けている間、連続で処理をするボタンをつくりたい。
たとえば、押している間ミサイルを連写するとか。
UILongPressGestureRecognizerの state.beganでタイマーのリピート処理を始め、 state.endedで Timer.invalidate( ) することで実現できます。

@objc func repeateWhilePressing(_ gestureRecognizer : UILongPressGestureRecognizer){
        switch gestureRecognizer.state {
            case .began:
        print("shot!")//短いタップでも反応するようにここにも処理を書いておきます。

        pressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (Timer) in
                       print("shot!")//押している間0.1秒間隔で繰り返し。
                   })
               case .ended:
                   pressTimer?.invalidate()
               default:
                   break
               }
    }

短いタップの場合も反応するように、Long Pressの最小プレス時間を小さく設定。

let longPress = UILongPressGestureRecognizer(target: self, action: #selector(repeateWhilePressing()))
longPress.minimumPressDuration = 0.01

押している間の結果

shot!
shot!
shot!
shot!
shot!


Core MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。

Twitter
MLBoysチャンネル
Medium

相棒
note

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

(Swift 5対応)UILabelの文字に縁取り(袋文字)をする2つの方法

どうも、肩と背中の日焼けが激しすぎて服を着ないまま禁断の在宅勤務を行なっているiOSエンジニアのkaです。

文字の表示を簡単にネイティブの機能だけを使ってテレビ番組のテロップ風にしたいということでやってみました。

NSAttributedStringを使う方法

swift.swift
let stroke:UIColor = UIColor.black
let foreground:UIColor = UIColor.white
let width:CGFloat = 3.0

let strokeTextAttributes = [
    .strokeColor : stroke,
    .foregroundColor : foreground,
    .strokeWidth : width,
    .font : UIFont.boldSystemFont(ofSize: 20.0)
    ] as [NSAttributedString.Key : Any]
let string = NSMutableAttributedString.init(string: "縁取り文字", attributes: strokeTextAttributes)
label.attributedText = string

スクリーンショット 2020-08-20 11.56.36.png

CGContextを使う方法

UILabelのサブクラスを作ります

DecoreateLabel.swift
class DecorateLabel: UILabel {

    var strokeColor:UIColor = UIColor.white
    var strokeSize:CGFloat = 2.0

    override func drawText(in rect: CGRect) {
        if let context = UIGraphicsGetCurrentContext() { 
            let textColor = self.textColor

            context.setLineWidth(self.strokeSize)
            context.setLineJoin(CGLineJoin.round)
            context.setTextDrawingMode(.stroke)
            self.textColor = self.strokeColor
            super.drawText(in: rect)

            context.setTextDrawingMode(.fill)
            self.textColor = textColor
        }
        super.drawText(in: rect)
    }
}

swift.swift
let label = DecorateLabel()
label.frame = CGRect(x: 150, y: 200, width: 200, height: 20)
let stroke:UIColor = UIColor.black
let foreground:UIColor = UIColor.white
let width:CGFloat = 8.0
label.strokeColor = stroke
label.textColor = foreground
label.strokeSize = width
label.text = "縁取り文字"

スクリーンショット 2020-08-20 12.09.52.png

補足

NSAttributedStringを使う方法だと文字の内側に縁取り
CGContextを使う方法だと文字の外側に縁取りをします
場面によって使い分けましょう!

参考:

storyboard で UILabel を袋文字にする
UILabelの文字に枠をつけ、袋文字(縁取り文字)を作る
UILabelをNSAttributedStringで文字装飾(Swift 4対応)

終わりです

Brewus,Inc.
株式会社ブリューアス
https://brewus.co.jp

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

COCOA PODSでAlamofireとSwiftyJSONをインストールし、API通信。

Alamofireと、URLSessionの比較についてメモ書き。
SwiftyJSONも。

 はじめに

 COCOA PODS?

iOS開発での、パッケージ管理システム。
javascriptでいう、npmやyarn

 Alamofire?

iOS開発での、定番のAPIクライアント。
iOSのネットワーク通信処理をシンプルにする目的。

 SwiftyJSON?

Alamofireで取得したデータを、JSON形式で扱う。

 インストール手順

【Swift】COCOAPODSでAlamofireとSwiftyJSONをインストールし、API通信をする方法

 コード比較 (Webリクエスト)

Alamofireの、良いところ。

  • (data, response, error)が、(response)に集約
  • do-catchが不要
  • JSONSerialization.jsonObjectが不要
  • task.resume()が不要

 URLSession

func getRandomPersonUrlSession(id: Int, completion: @escaping PersonResponseCompletion) {

        guard let url = URL(string: "\(PERSON_URL)\(id)") else { return }

        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in

            guard error == nil else {
                debugPrint(error.debugDescription)
                return
            }


            guard let data = data else { return }

            do {
                let json = try JSONSerialization.jsonObject(with: data, options: [])
                guard let jsonAny = json as? [String: Any] else { return }

                let person = self.parsePersonManual(json: jsonAny)

                DispatchQueue.main.async {
                    completion(person)
                }
            } catch {
                debugPrint(error.localizedDescription)
                return
            }
        }
        task.resume()
    }

 Alamofire

func getRandomPersonAlamo(id: Int, completion: @escaping PersonResponseCompletion) {

        guard let url = URL(string: "\(PERSON_URL)\(id)") else { return }
        Alamofire.request(url).responseJSON { (response) in
            if let error = response.result.error {
                debugPrint(error.localizedDescription)
                completion(nil)
                return
            }

            guard let json = response.result.value as? [String: Any] else { return
                completion(nil) }
                let person = self.parsePersonManual(json: json)
                completion(person)
            }
    }

Alamofireを使うと、遥かに簡潔で見栄えが良くなりました。

 コード比較 (JSON解析)

SwiftyJSONの、いいところ

  • as? [String: Any]が不要。(=キャストが不要)

 ライブラリなし。

// Persing function using manual methods
    private func parsePersonManual(json: [String: Any]) -> Person {
        let name = json["name"] as? String ?? ""
        let height = json["height"] as? String ?? ""
        let mass = json["mass"] as? String ?? ""
        let hair = json["hair_color"] as? String ?? ""
        let birthYear = json["birth_year"] as? String ?? ""
        let gender = json["gender"] as? String ?? ""
        let homeWorldUrl = json["homeworld"] as? String ?? ""
        let filmUrls = json["films"] as? String ?? ""
        let vehicleUrls = json["vehicles"] as? [String] ?? [String]()
        let starshipUrls = json["starships"] as? [String] ?? [String]()

        return Person(name: name, height: height, mass: mass, hair: hair, birthYear: birthYear, gender: gender, homeWorldUrl: homeWorldUrl, filmUrls: filmUrls, vehicleUrls: vehicleUrls, starshipUrls: starshipUrls)
    }

 SwiftyJSON

 // Parsing with SwiftyJSON
    private func parsePersonSwifty(json: JSON) -> Person {
        let name = json["name"].stringValue
        let height = json["height"].stringValue
        let mass = json["mass"].stringValue
        let hair = json["hair_color"].stringValue
        let birthYear = json["birth_year"].stringValue
        let gender = json["gender"].stringValue
        let homeWorldUrl = json["homeworld"].stringValue
        let filmUrls = json["films"].stringValue
        let vehicleUrls = json["vehicles"].arrayValue.map({$0.stringValue})
        let starshipUrls = json["starships"].arrayValue.map({$0.stringValue})

        return Person(name: name, height: height, mass: mass, hair: hair, birthYear: birthYear, gender: gender, homeWorldUrl: homeWorldUrl, filmUrls: filmUrls, vehicleUrls: vehicleUrls, starshipUrls: starshipUrls)
    }

記述量はあまり変わらないけど、大量のas? [String: Any]が不要。
配列ではmapを使う。

おしまい。
何かあれば、追記します。

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