iOS教學 – 日期時間處理(NSDate等物件使用)

取得日期聽來容易,不過實際上使用時很快就發現:哇!怎麼沒有想像的簡單?
實際上依個人經驗,無論什麼程式語言,甚至 MySQL ,日期與時間的處理從來都沒有簡單過,雖然不致於到十分困難,不過若是小看它隨便寫的話,最終一定會遇到問題。
… 話說回來,程式語言的發展也歷經半世紀以上的時光,為何到現在還無法統一日期時間的處理方式呢?新的語言雖然帶來了新的方法,必然有其優點,不過無法完全覆蓋舊的作法,結果也是導致又多了一個時間日期的處理方式罷了。

 Github 目錄

本文中的程式碼皆可在範例 project 中找到:
https://github.com/orangedream/DateExampleiOS

NSDate

NSDate 物件單純只是表示一個特定的時間點。進一步的說,NSDate裡記錄的是自 2001/1/1 以來的時間,時區是 GMT+0。
要取得目前的時間十分簡單:

     NSDate *currentDate=[NSDate date];

要印出來也還算簡單…:

    NSLog(@"currentDate=%@",currentDate);

執行結果(模擬器):

     currentDate=2016-02-07 04:18:50 +0000

執行結果(iPhone):

     currentDate=2016-02-08 00:17:55 +0000

!?這時間看來不太對,仔細看可以看到最後面有個 +0000,這就是表示這個是以 GMT+0 的時區來列印的。簡單來說,這是美國時間,因為台灣時間是 GMT+8。所以說無法直接使用,要先作轉換。

印出當地時間 Local Time

直接用 NSLog() 列印 NSDate 的結果,會得到美國時間,若要取得當地時間,則要借助 NSDateFormatter 的幫忙作以下的轉換:

    NSDate *currentDate=[NSDate date];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateStyle:NSDateFormatterShortStyle];
    [formatter setTimeStyle:NSDateFormatterMediumStyle];
    NSString *currentDateString = [formatter stringFromDate:currentDate];
    NSLog(@"currentDate=%@", currentDateString);

執行結果(模擬器):

     currentDate=2/8/16, 8:24:35 AM

執行結果(iPhone):

    currentDate=2016/2/8 上午8:24:06

!?為何模擬器與 iPhone 的執行結果會不同?這跟地區( NSDateFormatter.locale )屬性初始值以及時區( NSDateFormatter.timeZone )屬性初始值有關。
用這段 code 來找出 NSDateFormatter.locale 與 NSDateFormatter.timeZone 為何:

NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
NSLog(@"NSDateFormatter.locale=%@",formatter.locale.localeIdentifier);
NSLog(@"NSDateFormatter.timeZone=%@",formatter.timeZone);

執行結果(模擬器):

 NSDateFormatter.locale=en_US
 NSDateFormatter.timeZone=Asia/Taipei (GMT+8) offset 28800

執行結果(iPhone):

NSDateFormatter.locale=zh_TW
NSDateFormatter.timeZone=Asia/Taipei (GMT+8) offset 28800

印出台灣時間 Local Time

所以,若不管手機的地點為何,要列印出台灣時間的話,就可以這麼作:

    NSDate *currentDate=[NSDate date];
    NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
    [formatter setDateStyle:NSDateFormatterShortStyle];
    [formatter setTimeStyle:NSDateFormatterMediumStyle];
    [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"zh_Hant_TW"]];
    [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"Asia/Taipei"]];
    NSString *currentDateString = [formatter stringFromDate:currentDate];
    NSLog(@"%@", currentDateString);

執行結果(模擬器):

 Taiwan time=2016/2/8 上午8:52:14

執行結果(iPhone):

 Taiwan time=2016/2/8 上午8:53:12

 

註:時區列表可以用 [NSTimeZone knownTimeZoneNames] 來取得,而地區列表可以用 [NSLocale availableLocaleIdentifiers] 來得到。

格式化日期輸出

前面的日期輸出使用了內建的格式:NSDateFormatterShortStyle與NSDateFormatterMediumStyle,如果我們要輸出像「2 月 8 日 (星期一)」這樣的格式,可以這麼作:

// setup date format
 NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
 [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"zh_Hant_TW"]];
 [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"Asia/Taipei"]];
 [formatter setDateFormat:@"M 月 d 日 (eeee)"];
 // Date to string
 NSDate *now = [NSDate date];
 NSString *currentDateString = [formatter stringFromDate:now];
 NSLog(@"currentDate=%@", currentDateString);

 

台灣常見的幾個輸出格式範例:

2016年 2月 9日

 YYYY年 M月 d日

2016年 2月 9日 23:09:00

 YYYY年 M月 d日 HH:mm:ss

2016/2/9

YYYY/M/d

2016/02/09

YYYY/MM/dd

2/9 21:00

 M/d HH:mm

2/9 9:00 下午

M/d h:mm a

註:格式詳列:Date Format Patterns

民國日期

如果我們想列出民國的年曆,像「民國105年 2月 9日」,就會需要轉換日曆(Calendar)物件才能輸出民國年份:

 // setup date format
 NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
 [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"zh_Hant_TW"]];
 [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"Asia/Taipei"]];
 [formatter setDateFormat:@"民國yyy年 M月 d日 (eeee)"];
 [formatter setCalendar:[[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierRepublicOfChina]];
 // Date to string
 NSDate *now = [NSDate date];
 NSString *currentDateString = [formatter stringFromDate:now];
 NSLog(@"currentDate=%@", currentDateString);

農曆

比照前例,我們可以將日期轉換為農曆

 // setup date format
 NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
 [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"zh_Hant_TW"]];
 [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"Asia/Taipei"]];
 [formatter setDateFormat:@"UUU年 M月 d日"];
 [formatter setCalendar:[[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierChinese]];
 // Date to string
 NSDate *now = [NSDate date];
 NSString *currentDateString = [formatter stringFromDate:now];
 NSLog(@"currentDate=%@", currentDateString);

輸出(模擬器):

丙申年 1月 2日

由字串轉換為 NSDate

要將串轉換為 NSDate 需要再度借助 NSDateFormatter 的幫助:

    NSString *dateString = @"2016/2/9";
    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    // Make sure it's matched format!
    [dateFormatter setDateFormat:@"yyyy/M/d"];
    NSDate *date = [[NSDate alloc] init];
    date = [dateFormatter dateFromString:dateString];
    NSLog(@"date=%@", date);

輸出(模擬器):

date=2016-02-08 16:00:00 +0000

為何輸出不是 2016-02-09 00:00:00 呢?請參考前面,因為 Timezone 的不同,在 GMT+8 的地方的「2016-02-09 00:00:00」會等於 GMT+0的[2016-02-08 16:00:00]

2013/1/30~2016/2/9之間有幾天

要計算二個 NSDate 之間有幾天,就要借助NSDateComponents 與 NSCalendar 來計算。(注意:使用NSDate.timeIntervalSinceDate/86400 秒的作法在大多數情況下可行,然而遇到日光節約時間就會出錯!)

    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"yyyy/M/d"];
    NSDate *date1 = [dateFormatter dateFromString:@"2013/1/30"];
    NSDate *date2 = [dateFormatter dateFromString:@"2016/2/9"];
    NSDateComponents *components;
    NSInteger numberOfDays;
    components = [[NSCalendar currentCalendar] components: NSCalendarUnitDay fromDate: date1 toDate: date2 options: 0];
    numberOfDays = [components day];
    NSLog(@"Total days=%ld", numberOfDays);

輸出:

 Total days=1105

“07:09” ~ “23:12” 之間有幾分鐘

同樣的,我們也可以延伸上例來計算二個時間的間隔:

    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"HH:mm"];
    NSDate *date1 = [dateFormatter dateFromString:@"07:09"];
    NSDate *date2 = [dateFormatter dateFromString:@"23:12"];
    NSDateComponents *components;
    NSInteger numberOfMinutes;
    components = [[NSCalendar currentCalendar] components: NSCalendarUnitMinute fromDate: date1 toDate: date2 options: 0];
    numberOfMinutes = [components minute];
    NSLog(@"Total minutes=%ld", numberOfMinutes);

取得今天一開始的時間

借由 NSCalendar.startOfDayForDate 可以取得一天一開始的時間

    NSDate *now=[NSDate date];
    NSCalendar *cal=[NSCalendar currentCalendar];
    NSDate *beginOfToday= [cal startOfDayForDate:now];
    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"yyyy/M/d HH:mm:ss"];
    NSLog(@"Begin of today=%@",[dateFormatter stringFromDate:beginOfToday]);

取得一個月一開始的時間

若要取得本月一開始的時間,就要借由 NSDateComponents 來處理,自現在時間只取 年/月/日 三個屬性,然後把日期設為1, 即可達成任務。

    NSDate *now=[NSDate date];
    NSCalendar *cal=[NSCalendar currentCalendar];
    NSDateComponents *comp = [cal components:(NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay) fromDate:now];
    [comp setDay:1];
    NSDate *firstDayOfMonthDate = [cal dateFromComponents:comp];
    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"yyyy/M/d HH:mm:ss"];
    NSLog(@"Begin of this month=%@",[dateFormatter stringFromDate:firstDayOfMonthDate]);

換算自某個日期之後30天後的日期

同樣,要對 NSDate 作計算,NSDateComponents是十分重要的工具,透過NSCalendar.dateByAddingComponents 即可輕易完成任務。

    NSDate *now=[NSDate date];
    NSDateComponents *dateComponents = [[NSDateComponents alloc] init];
    [dateComponents setDay:30];
    NSDate *thirtyDaysLatter = [[NSCalendar currentCalendar] dateByAddingComponents:dateComponents toDate:now options:0];
    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:@"yyyy/M/d HH:mm:ss"];
    NSLog(@"Begin of this month=%@",[dateFormatter stringFromDate:thirtyDaysLatter]);

 

要注意的是,這裡並沒有重設幾時幾分,所以會變成30天前的同一時間。使用上依需求可能要作些調整。參考前面的例子應該可以組合出你所要的結果。

與 Unix(PHP) timestamp 互換

由於 iOS App 常與後台 API 作搭配,而無論是自行建立的後台伺服器或是使用現成的雲端平台,總免不了要作 timestamp 的處理。而 PHP(Unix)或是 MySQL 的 timestamp
不意外的,與 iOS 的 timestamp 並不相同。幸好,轉換的方式並不困難,因為一方是 1970 開始的 timestamp,而另一方則為 2001 年開始的 timestamp,所以只要這樣轉換即可:

    NSDate *now=[NSDate date];
    NSTimeInterval timeStamp = [now timeIntervalSince1970];
    // NSTimeInterval is defined as double
    NSNumber *timeStampObj = [NSNumber numberWithDouble: timeStamp];
    NSLog(@"Unix timestamp=%ld", [timeStampObj longValue]);

其它有關 NSDate 處理教學

有關 NSDate 的處理線上文章不少,不過多半過於零碎,以下這篇我覺得寫得不錯,值得推薦:
Date Programming

iOS App開發 – UIViewController 的七種切換頁面技巧

在開發 iOS App 時,UIViewController 之間的切換是很基本且必要的動作。

除非你只是要開發一個超精簡的單頁的控制程式,不然一定會寫到這個動作。

本篇可參考範例程式:https://github.com/iOTEC/ViewControllerSwitchExample

基礎篇

有一件事要先對新手說明:基礎並不是表示不好用或是比較遜,反而,若能用基礎的方式完成就一定要用這裡的方式才是正確的,老手是把難的程式寫得簡單,新手反而才會把簡單的程式寫得很複雜,然後還常產生一堆 Bug。

只有當簡單的方式無法達成任務的時候,才需要找尋更進階的作法。

Method 1. 用 Storyboard 的 Segue 切換方式

這是最簡單的方。參考專案裡的Storyboard裡的 Root 裡的 「Method 1. to A」按鈕,先點選,然後壓住 Ctrl 的情況下,將按鈕拖至 A 再放開,就會出現這個選單:

image001

選擇「Show」,這樣就大功告成了!一行程式都不用寫,簡單吧!? 這個技巧文字上比較不容易理解,不過真正試一次就會懂了。

而且,如果需要在切換頁之前先作點什麼處理的話,可以在程式裡加上prepareForSegue函式來進行

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender {
}

Method 2. 用 performSegueWithIdentifier 來執行預先定義好的 Segue

1. 在 Storyboard 裡先點選 Root ,然後拖至 B ,產生一個 Segue

2. 點選該 Segue,我們給它個名字叫 RootToB好了

image003

3. 點 Assist Editor 切換為 Storyboard 跟 Code 並列模式

image005

4. 與 Method 1 同樣的技巧,點按鈕,然後拖至右方 code 處放開,在下方Name欄填入 method2Clicked

image007

5. 就會生成按鈕的連接函式:

image009

左方有個實心的點表示這個 method 與 Storyboard 裡的元件有作連結

6. 接著加上 code :

[self performSegueWithIdentifier:@"RootToB" sender:nil];

這樣就大功告成了。

這個作法適合在按了按鈕之後,還有要作許多前置作業,以及其它並不是由按鈕觸發的情況。彈性比 Method 1來得高,不過當然要作的事會多一點。

方式3. 用 Storyboard 的 Storyboard ID 來切換

參考專案的「Method 3. To D」

1. 先為 D 的Storyboard ID取個名字,叫「ViewControllerDIdentifier」
image011

2. 在ViewControllerRoot.m一開頭#import “ViewControllerD.h”

3. 參考Method 2 裡介紹的方法,為「Method 3. To D」按鈕產生一個連結的程式碼叫「method3Clicked」

然後填上以下的 Code 即大功告成

- (IBAction)method3Clicked:(id)sender {
   UIStoryboard *storyboard=[UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle mainBundle]];
   ViewControllerD *controllerD = [storyboard instantiateViewControllerWithIdentifier:@"ViewControllerDIdentifier"];
   [self.navigationController pushViewController:controllerD animated:YES];
}

Swift version

@IBAction func method3Clicked(_ sender: Any) {
    let mainStoryboard = UIStoryboard(name: "Main", bundle: Bundle.main)
    if let vc = mainStoryboard.instantiateViewController(withIdentifier: "ViewControllerDIdentifier") as? ViewControllerD
    {
        navigationController?.pushViewController(vc, animated: true)
    }
}

這個方式並不需要借助於 Segue 的幫助,而是使用 Storyboard ID 來任意取用所需要的頁面,彈性很高,也是很常用的方法。

Method 4. 回前頁的三種常作法

1. 什麼都沒寫,點左上的「<」按鈕自動會回上頁

這是預設的功能,很方便。

不過並不像 Segue 一樣,可以在回前頁之前作點什麼事,是比較不方便的地方。

2. 撰寫程式碼來回前頁

相對的,若需要前頁前作許多前置處理的話,可以改用:

[self.navigationController popViewControllerAnimated:YES];

3. 撰寫程式碼來回到首頁

假設你的程式一開始由 Root 頁,Push 了 A來到 A頁,然後再 Push 了 C 來到C頁,這時候若想要按一個按鈕即回到 Root 頁而不需要先回到 A 頁,可以用:

[self.navigationController popToRootViewControllerAnimated:YES];

進階篇

如果前面的4種都不適用的話,就要使用一些進階的技巧,在進行之前,要先對 NavigationController 有進一步的了解:

NavigationController 是 iOS 裡一個 Container 元件,真正呈現在使用者之前的是 UIViewController 或是 UITableViewController,而UINavigationController 則是負責作頁面切換等等的工作。

其運作的原理之一是 UINavigationController. viewControllers 陣列,這是我們這裡要借力的地方。viewControllers 陣列裡面存放了 ViewController Stack(堆疊),在 Push 時將目前的放進 Stack 裡,而在 Pop 時則將最上面的 ViewController 放出來。

以範例程式來說,其內的頁面切換結構如下圖:

image020

其 self.navigationController.viewControllers 陣列在每個頁面裡會是這樣:

navigationController.viewControllers stack
navigationController.viewControllers stack

Method 5. 直接回到前面的某一頁

如果很確定 viewControllers 的堆疊狀況,可以使用下面這段程式碼來進行:

NSArray *array = [self.navigationController viewControllers];
[self.navigationController popToViewController:[array objectAtIndex:堆疊編號] animated:YES];

不過這個程式碼的缺點是維護上不容易,若後面有需求變更了堆疊的順序,那麼就容易亂掉了。使用上要多小心。

Method 6. 直接回到前面的特定個 UIViewController

如果知道要回去的某頁的 Class 的話,可以這樣作:

for (UIViewController *controller in self.navigationController.viewControllers)
{
    if ([controller isKindOfClass:[你的UIViewController名 class]]){
        [self.navigationController popToViewController:controller animated:YES];
        break;
    }
}

Swift version

if let controllers = navigationController?.viewControllers {
    for vc in controllers {
        if vc is <你的UIViewController名>{
            navigationController?.popToViewController(vc, animated: true)
        }
    }
}

這個作法彈性就比 Method 5好一些,不會因為後來插了某一頁進來而亂掉。不過也不是不會有出錯的可能,假如說:用Method 3 的作法先 Push A 再 Push B 再 Push A 再 Push B, 這時堆疊會變成:

image018

若使用這段程式碼,它會跑到 Index=1 的 A 而不是 Index=3 的A。不過一般來說應該很少會遇到這種情況。

Method 7. 切換到並沒有 Push 過的 UIViewController,但是又不要成為目前頁面的下一頁

範例程式裡的切頁結構來說明:

image020

如果我打算由 C 切到 D ,那麼前面說明的 1~6 種作法立刻都不適用了。

比較接近的作法是 Method 3, 不過某使用 Method 3. 其實會創造出另一個路徑 而已,雖然也是 D沒錯,是按 「<」時會回到C而不是回到B。

image022

要解決這個問題,可以對 Stack 直接進行操作:

NSMutableArray *controllers = [NSMutableArray arrayWithArray:self.navigationController.viewControllers];
UIViewController *rootController = controllers[0]; // 保留 rootController
[controllers removeAllObjects]; // 移除全部
[controllers addObject:rootController]; // 先將 rootController 放到 index 0
ViewControllerB *vcB = [self.storyboard instantiateViewControllerWithIdentifier:@"ViewControllerBIdentifier"];
[controllers addObject:vcB]; // 建立並放入 B 在 index 1
ViewControllerD *vcD = [self.storyboard instantiateViewControllerWithIdentifier:@"ViewControllerDIdentifier"];
[controllers addObject:vcD]; // 建立並放入 D 在 index 2
[[self navigationController] setViewControllers:controllers animated:YES]; // 這行十分重要,要請 navigationController 重新用新的 stack 執行

Swift version

if let controllers = navigationController?.viewControllers{
    var newStack = [UIViewController]()
    if controllers.count > 0 {
        let rootVC = controllers[0]
        newStack.append(rootVC)
    }
    let mainStoryboard = UIStoryboard(name: "Main", bundle: Bundle.main)
    if let vcB = mainStoryboard.instantiateViewController(withIdentifier: "ViewControllerBIdentifier") as? ViewControllerB
    {
        newStack.append(vcB)
    }
    if let vcD = mainStoryboard.instantiateViewController(withIdentifier: "ViewControllerDIdentifier") as? ViewControllerB
    {
        newStack.append(vcD)
    }
    navigationController?.setViewControllers(newStack, animated: true)
}

於是神奇的,可以由 C 橫出跳至 D 頁,按「<」鍵也是回到B而不是回到 A,真是個聰明的作法!

相信有上述的七種方法,大部份的切頁需求都可輕易達成!

也希望本篇的分享能對有切頁疑惑的開發者有一些幫助。

By Orange@iotec.tw, 2016/02/02