Disable dark mode on iOS 13 in your application

在使用 Xcode 11 以後的版本開發時,應用程式會自動在 iOS 13 以後的系統跑 Dark mode;
而在還沒有準備好支援 dark mode 的情況下,會直接是一個悲劇的 UI⋯⋯

這時候你可以選擇⋯⋯

  • 調整大部分的 UI 來支援 Dark mode
  • 在你的應用程式中設立成一般模式

在你有新功能要上線且還未準備的情況下,建議你選擇後者會比較符合效益👌

方法就是這篇文章的主軸

file
就是在 Info.plist 設立 UIUserInterfaceStylelight 或是 dark

如果只有特定的 UIWindowUIViewControllerUIView等需要調整時,可以找到它們底下的這個參數 overrideUserInterfaceStyle 來去做調整即可👌

如何更改模擬器上的狀態列

這篇就來談談我是如何更改模擬器上的狀態列🎤

在 Xcode 11 Beta 3 以前,我是使用 SimulatorStatusMagic
而今天要弄截圖的時候發現,原來在 Xcode 11 Beta 4 之後,有內建的使用方法!

我喜歡讓 App store previews 上的時間顯示我自己的生日🎂算是一個小巧思(但沒人想知道)

而現在可以透過內建的指令來完成這件事,其中你可以使用下列這些

xcrun simctl status_bar

file

像是更改目前開著的模擬器時間:

xcrun simctl status_bar booted override --time "02:01"

成果圖:
file

而若是你的 Xcode 版本是 6 - 10 的話,就繼續使用 SimulatorStatusMagic 吧👌

SwiftUI + Google AdMob

這篇是一個簡單介紹 UIViewControllerRepresentable 的範例,
由於 Google AdMob 的 GADBannerView 不像上次提及的 UITextField 一樣,可以直接使用 UIViewRepresentable 來包裝;原因是它必須設置一個 rootViewController,也就意味著我們需要使用 UIViewControllerRepresentable 才能完成它。

Interfacing with UIKit

透過這個 Apple 官方的教學當中,我們可以從 UIPageViewController 的範例來做發想,故我的實作方式會是這樣:

import GoogleMobileAds
import SwiftUI
import UIKit

struct GADBannerViewController: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        let view = GADBannerView(adSize: kGADAdSizeBanner)
        let viewController = UIViewController()
        view.adUnitID = "your ad unit id in there."
        view.rootViewController = viewController
        viewController.view.addSubview(view)
        viewController.view.frame = CGRect(origin: .zero, size: kGADAdSizeBanner.size)
        view.load(GADRequest())
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

如果需要處理 Delegate 的部分

你可以參閱上次 TextField 的處理方式,建構一個 Coordinator 來進行相關的作業👌

如果還有問題的話

歡迎直接透過各種方式找到我,一起來討論討論 SwiftUI 的應用👍
程式碼會同步到 GitHub 上,有任何想法都可以直接留言📒

對於 SwiftUI onDisappear 的誤解?

在接觸 SwiftUI 的這段時間以來,我一直在試著釐清一件事情,那就是

onDisappear 到底是不是壞的!

這件事情很玄,畢竟網路上大部分的資訊都告訴我們 onAppear 類似於 viewDidAppearonDisappear 類似於 ViewDidDisappear,然後再補上一句

Note: In the current SwiftUI beta onDisappear will never be called.
by HackingWithSwift

或是你可以看到在 StackOverFlow 上大家是這麼討論的

file

接著,在這一路以來,你又曾經碰過真的是 Apple 的 bug,所以你就會很理所當然地認為⋯⋯

沒錯,onDisappear 就是壞的!

直到認真找找官方文件到底有沒有使用到 onDisappear 的範例,於是找到了這篇 並下載下來研究發現

onDisappear 是會動的⋯

這代表著一件事,就是其實是我誤解它的使用方式,而非它是壞的。
來看看官方的這個 View

struct ProfileHost: View {
    @Environment(\.editMode) var mode
    @State var profile = Profile.default
    @State var draftProfile = Profile.default

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            HStack {
                if self.mode?.wrappedValue == .active {
                    Button(action: {
                        self.profile = self.draftProfile
                        self.mode?.animation().wrappedValue = .inactive
                    }) {
                        Text("Done")
                    }
                }

                Spacer()

                EditButton()
            }
            if self.mode?.wrappedValue == .inactive {
                ProfileSummary(profile: profile)
            } else {
                ProfileEditor(profile: $draftProfile)
                    .onDisappear {
                        self.draftProfile = self.profile
                    }
            }
        }
        .padding()
    }
}

想了一下,若是我的話我會將 onDisappear 寫在哪裡?應該會是在 ProfileEditorbody 裡頭吧。而實際上測試了,在 ProfileEditor.body 裡頭實作

.onDisappear { print("disappear") }

是沒有效果的,我才得到一個結論

onDisappear 和 viewDidDisappear 不同

onDisappear 的概念是監聽你底下那個消失的動作

Adds an action to perform when this view disappears.

但並不是像 UIViewController.viewDidDisappear 一樣,是對物件本身消失去做動作。

因為大多數的文章都習慣將 onAppearviewDidAppear 做對比,也就造成我自己對於使用方式上產生誤解;實際上 onDisappear 的動作應該做在 superView 之中,而非直接寫在那個 struct 裡頭。

這大概就是一個從 UIKit 轉到 SwiftUI 才會誤解的地方了⋯⋯如果是一開始就從官方文件開始用 SwiftUI 學習寫 iOS 的人,應該不會陷入這種迷思🤷‍♂️

📒 SwiftUI + CoreData 的實戰心得🔥

SwiftUI + CoreData

file

這篇文章會紀錄我在目前的 side project 上,如何在 SwiftUI 下導入 CoreData;
而如同 在 SwiftUI 處理中文輸入法所會遇上的問題 所提及的,SwiftUI 身為一個還在測試階段的 framework,我們必須將當下的開發環境紀錄下來,以避免造成日後官方修正所造成的誤解。

開發環境

  • Xcode 11 Beta 7(但顯示為 Beta 6 - 11M392r )
  • macOS Catalina - 19A546d
  • iOS 13.1 Beta

使用情境

我要做一個貨幣的列表,並讓使用者可以對相對應的貨幣做隱藏與否,所以規格大概是需要一個 List,而 Row 裡頭呈現貨幣的名稱以及用 Toggle 來做控制隱藏的開關。

CoreData Model - Currency

file

Row 的部分

Xcode 11 Beta 5 之後NSManagedObject 可以視為一個 @ObservedObject,所以我們可以不必再弄一個 ManageRowModel,而是直接使用 NSManagedObject 來連動。
這邊我需要以英文大寫來顯示貨幣名稱以及一個控制是否顯示在主畫面上的開關。

struct ManageRow: View {
    @ObservedObject var currency: Currency

    var body: some View {
        HStack {
            VStack {
                Text(currency.name?.uppercased() ?? String())
                    .font(.title)
                    .fontWeight(.bold)
            }
            .padding()
            Toggle(isOn: $currency.isPresented) {
                Text(String())
            }
            .padding()
        }
    }
}

畫面如下方所呈現的樣式:

file

我們可以直接讓 Toggle(isOn: _)Currency.isPresented 連動,這樣便可以直接修改到相對應的值。

View 的部分

在 SwiftUI 裡頭有提供一個 @FetchRequest(fetchRequest: 的 propertyWrapper,而若要使用的話,記得要一併宣告 NSManagedObjectContext@Environment 之中,否則會報錯:

struct ManageView: View {
    @Environment(\.managedObjectContext) private var context
    @FetchRequest(fetchRequest: fetchRequest()) var currencies: FetchedResults

    var body: some View {
        List(currencies, rowContent: ManageRow.init)
    }

    static func fetchRequest() -> NSFetchRequest<Currency> {
        let request: NSFetchRequest = Currency.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Currency.name, ascending: true)]
        return request
    }
}

這樣就可以在畫面建立時,透過 SwiftUI 的機制去執行 fetchRequest 並呈現出來。

⚠️ 注意事項

Identifible 的使用

若要在 List 之中直接使用 NSManagedObject 作為 RandomAccessCollection,可以讓它符合 Identifible

extension Currency: Identifible {}

但這個意味著我們會以預設的 id,如 NSManagedObject.objectId 作為是否需要重新繪製畫面的依據,而在這邊會遇上一個問題:
當我們將這個 List 作為另一個畫面的 Sheet 時,當它出現時,並不會重新繪製 List
也就是說已經在畫面上的 Row,會出現第一次畫面的狀態且之後 Sheet 出現都還會保持一樣的畫面。

簡單來說就是開關的狀態不會改變,除非使用者自己上下滑動觸動 SwiftUI 重新繪製的機制

所以我必須讓系統知道,isPresented 若有改變過的話,需要更新 row 的畫面

extension Currency: Identifiable {
    public var id: String { "\(String(describing: name)) \(isPresented)" }
}

這邊我目前的作法是將 id 包含了 isPresented 的狀態,所以當同一個 objectId 有不同的 isPresented 時,對於 List 是不同的 Row,這時就會重新繪製成正確的畫面。

@Environment(.managedObjectContext) 的使用

我這邊的使用情境是,使用者點擊一個設定的按鈕會跳出一個 ManageView,而 ManageView 可以點擊儲存或是直接滑掉放棄當前操作。
所以在前一個畫面上,我們需要為它設立 NSManagedObjectContext,並在 onDismiss 時捨棄掉這次的操作。

struct ContentView: View {
    ...
    var body: some View {
        ...
        .sheet(isPresented: $isManagePresented,
                   onDismiss: { CoreDataStack.shared.backgroundContext.rollback() },
                   content: manageView)
    }

    private func manageView() -> some View {
        ManageView().environment(\.managedObjectContext, CoreDataStack.shared.backgroundContext)
    }
}

以上便是目前在 SwiftUI 上實作 CoreData 的分享📒

SwiftUI 上的鍵盤處理方式

在 iOS 的開發過程之中,難免會碰到一個狀況,那便是 UITextField/ UITextView 被鍵盤所遮住了⌨️
在 UIKit 之下,多數人會使用套件來做全域的處理,如 IQKeyboardManager 就是一個十分經典的解決方案。

來說說 SwiftUI 上的鍵盤處理方式

在 SwiftUI 上,我們也可以很優雅地處理這一塊,如在 List 元件中,只需要分別監聽 UIWindow.keyboardWillShowNotificationUIWindow.keyboardWillHideNotification,以及加上個 .animation(.default) 來優化使用者體驗。

var body: some View {
        List(viewModel.rowModels, rowContent: DemoRow.init)
            .padding(EdgeInsets(top: 0, leading: 0, bottom: bottomPadding, trailing: 0))
            .onReceive(NotificationCenter.default.publisher(for: UIWindow.keyboardWillShowNotification),
                       perform: updateFrame)
            .onReceive(NotificationCenter.default.publisher(for: UIWindow.keyboardWillHideNotification),
                       perform: updateFrame)
            .animation(.default)
    }

完整的 struct 可以在 GitHub 上查看👍

成果動畫

有任何問題歡迎在底下留言👏有寫法上的建議可以直接在 GitHub 上反應👍
有想看看一些廢話的話則是可以在 Twitter 直接找到我喔!😂

在 SwiftUI 處理中文輸入法所會遇上的問題

最近開始在嘗試把玩 SwiftUI 並打算作為一個 side project 的主要 UI 編寫方式,這篇文章則是其中一個使用情境所遇上的困難處。

開發環境

  • Xcode 11 Beta 7(但顯示為 Beta 6 - 11M392r )
  • macOS Catalina - 19A546d
  • iOS 13.1 Beta

記錄這點蠻重要的,因為可能過個幾版這篇文章就沒參考價值了😂
還沒有下載更新的朋友,可以快去更新一下!

使用情境

我要來做一個搜尋的功能,讓使用者可以輸入關鍵字,並自動搜尋完後將結果呈現在下方👏

於是我們可以用兩個 UI 元件來達成這件事

  • TextField
  • List
    並寫一個 @Binding var 或是 @ObservedObject var 來讓 TextField 的 text 有個 binding 的地方,當它的值有更新時,觸發搜尋的動作🚀
    而搜尋完的結構再來更新畫面上的 List

聽起來沒什麼毛病,對吧?

實際上你會遇上的問題⋯

當你想和我一樣,用拼音的輸入方式時;不論是你拼音還是注音,當你按下第一個音時,便會跑一次上述的流程了⋯⋯
而我們所期望的流程應該是使用者選完字後再進行搜尋,這點在目前的 TextField 是做不到的,因為它不會判別目前是否有還沒拼完音的狀況就發動了。

那該怎麼辦呢?

喵神這麼說

你可以查看一下當時的相關推文

我的作法就是以 UIViewRepresentable 來包裝,詳細的程式碼可以到 GitHub 查看👍

成果

當輸入了 luo dong 但是還沒選字的話並不會有動作。


而當選字後便會進行搜尋🔍

如果還有什麼問題的話,歡迎留言討論👏

建立 Xcode 的檔案範本

什麼是範本?

在新增檔案的時候,會有預設的這幾種類型可以選擇;而當我們有一些自己常用的架構,如 Coordinator、ViewModel 等,可以自建一個範本來使用。
下面舉例幾個預設的 Swift 檔案

Swift File

位置在
/Applications/Xcode.app/Contents/Developer/Library/Xcode/Templates/File Templates/Source/Swift File.xctemplate

//___FILEHEADER___

import Foundation

SwiftUI View

位置在
/Applications/Xcode-beta.app/Contents/Developer/Library/Xcode/ExtraTemplates/File Templates/User Interface

//___FILEHEADER___

import SwiftUI

struct ___FILEBASENAMEASIDENTIFIER___ : View {
    var body: some View {
        Text(/*@START_MENU_TOKEN@*/"Hello World!"/*@END_MENU_TOKEN@*/)
    }
}

#if DEBUG
struct ___FILEBASENAMEASIDENTIFIER____Previews : PreviewProvider {
    static var previews: some View {
        ___FILEBASENAMEASIDENTIFIER___()
    }
}
#endif

這些是跟著 Xcode.app 走的,而若是我們使用者自定義要使用的,得放在
/Users/UserName/Library/Developer/Xcode/Templates
先建立一個資料夾,或是可以到上述的位置複製一份過來。
需要至少三個檔案

  • FILEBASENAME.swift
  • TemplateIcon.png
  • TemplateInfo.plist

我自己的使用方式是複製系統的 Swift file 來做修改 🔧
也比較好去熟悉可替換文字和 header 的使用方式 👍

Resign .ipa

前言

我們都知道 iOS 是使用 ipa 檔案來安裝 app,而該如何換掉 ipa 裡頭的憑證呢?

解壓縮

首先,我們先將 Application.ipa 解壓縮,可以得到一個 PayLoad 的資料夾,裡頭包含著一個應用程式 Application

移除舊有的簽章

接著我們透過 terminal 來進行移除的動作

rm -rf PayLoad/Application/_CodeSignature

找出你打算簽章的 provisionprofile

你可以透過 Apple developer 的網頁直接下載,或是查看本機裡頭的檔案,路徑會是在:

~/資源庫/MobileDevice/Provisioning Profiles/

用 Finder 到那路徑之後,可以使用空白鍵來進行預覽,
透過名稱以及 SHA-1 來辨別。

嵌入 mobileprovision 到 app 之中

接著就複製並改名字放入 app 中

cp yourProvisionProfile.mobileprovision Payload/Application.app/embedded.mobileprovision

來製作簽章所需要的 entitlements.plist

先將 .mobileprovision 輸入成 profile.plist

security cms -D -i yourProvisionProfile.mobileprovision  > profile.plist

再來利用 profile.plist 輸出成 entitlements.plist

/usr/libexec/PlistBuddy -x -c 'Print :Entitlements' profile.plist > entitlements.plist\

記得 App Bundle Identifier 得和簽章的名稱一致

/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier tw.Archie.Application" Payload/Application.app/Info.plist

就可以開始簽章了!

我們先透過 security 來找尋並複製剛剛 .mobileprovision 的 SHA-1

security find-identity -p codesigning -v

這邊記得要從 Frameworks 開始一個一個簽,再來才是 App 本身;
假設你的 mobileprovision SHA-1 為 B77ED97FBFC708A802BDB159CC2ED4E2A4472A09

codesign -f -s B77ED97FBFC708A802BDB159CC2ED4E2A4472A09 --entitlements entitlements.plist Payload/Application.app/Frameworks/someFramework.framework

一個一個簽章完之後,便可以簽署 app 了

codesign -f -s B77ED97FBFC708A802BDB159CC2ED4E2A4472A09 --entitlements entitlements.plist Payload/Application.app/Application

最後確認是否有簽對

我們可以透過 codesign 來查詢

codesign -vv -d Payload/Application.app

如果資訊都沒錯的話,就來輸出吧!🎉

zip -qr Resigned.ipa Payload

題外話,使用 Xcode 安裝

就在 Xcode 之中打開 Devices and Simulators 視窗,並將剛剛最後產出的 ipa 直接拖拉到你的裝置即可!

CoreData with background task

前言

雖然在 iOS 上開發了幾年的時間,但一直到最近才開始使用 CoreData;之前在第一份工作的專案之中是使用 FMDB 來處理資料存取,而後續則是用了 Realm

選擇的原因分別是當時 FMDB 的速度較其餘兩者快速,而後來看上 Realm 的跨平台特色,不過近期開發的感想是能以原生為主的話,就儘量降低對於第三方套件的依賴性。

這篇文章會記錄些什麼

其實這篇文章並不會從頭到尾寫下教學,而把重點放在一些我踩到的雷上,像是⋯⋯

記得要附上 sqlite 的路徑

原先我的 persistentContainer 的產生方式如下

lazy var persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "OfflineWallet")
    let description = NSPersistentStoreDescription()
    description.shouldInferMappingModelAutomatically = true
    description.shouldMigrateStoreAutomatically = true
    container.persistentStoreDescriptions = [description]
    container.loadPersistentStores { _, error in
        if let error = error {
            fatalError("Unresolved error \(error), \(String(describing: error._userInfo))")
        }
    }
    return container
}()

在模擬器上存取了幾次,每次都有 save 且第二次進入畫面的時候,都可以 fetch 得到資料,但是只要重開 App 就會從頭來過⋯⋯

也就是說其實都只是像是存在 NSManagedObjectContext 上,而並沒有實際地轉成 sqlite

補上指定的 URL 即可解決。

...
let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
let sqliteURL = documentsDirectoryURL?.appendingPathComponent("OfflineWallet.sqlite")
description.url = sqliteURL
...

performBackgroundTask v.s newBackgroundContext

要實作 backgroundTask 有兩種作法

NSPersistentContainer.performBackgroundTask

persistentContainer.performBackgroundTask { context in
    //do something in background thread
}

NSPersistentContainer 會建立一個 context 在這個 closure 裡頭使用,而重點是當這條 thread 結束之後,這個 context 所管理的物件(NSManagedObjectModel)的所有變數會釋放掉,也就是都會變成 nil

適合用於取得資料並轉型成其他 class / structure 的時候使用。

newBackgroundContext

而如果你必須在後續的程式之中繼續使用 context 所產生的 NSManagedObjectModel 時,你就得要保存其 context;像是建立一個 backgroundContext 並存下來使用:

lazy var backgroundContext = persistentContainer.newBackgroundContext()

//you should use this context to do something what you want
backgroundContext.perform {
//do something like before
}

此時由於這個 context 並沒有被釋放掉,所以其 NSManagedObjectModel 的所有變數便也會持有著;而依然是在其他 thread 上進行,並不會佔據 main thread。

待續

分頁讀取

筆記一下 NSFetchRequest 有提供 fetchLimit 以及 fetchOffset 可以用來做分頁讀取的功能。

Bitnami