NSDate, NSDateFormatter, NSDateComponents et la ronde des heures.

berfisberfis Membre
janvier 2014 modifié dans API AppKit #1

Bonsoir,


 


Me revoilà  aux petites heures à  me battre avec... les heures, justement.


 


Je souhaite "réduire" une date à  son jour-mois-année, en laissant tomber tout ce qui est plus précis.


 


En effet, savoir si une date tombe "aujourd'hui" "avant" ou "après" n'est pas si simple (pour moi). Et à  voir les nombreuses discussions sur SO, les github consacrés aux catégories sur NSDate, je ne suis pas le seul à  me poser la question.


 


Je pensais d'abord enregistrer simplement une date future avec une NSDate, mais ensuite comment vérifier qu'elle tombe "aujourd'hui". Non, ne me sortez pas "isEqualToDate", puisque cet aujourd'hui-là  ne va durer qu'une fraction de seconde.


 


J'ai ensuite envisagé de passer par un NSDateFormatter, qui "raboterait" les minutes et autres fractions et me permettrait un calcul plus simple sur le nombre de jours entre ma date agendée et aujourd'hui. Mais ça me donne des résultats bizarres:



NSDateFormatter *datefFormatter = [[NSDateFormatter alloc]init];
[datefFormatter setDateFormat:@dd.MM.yyyy];
NSDate *zeroed = [datefFormatter dateFromString:[datefFormatter stringFromDate:[NSDate date]]];
NSLog(@%@",zeroed);

Me donne 2014-01-04 23:00:00 +0000 alors que nous sommes le 5, passé de plusieurs heures (hélas).


 


Alors comment faire ? Je demande cela parce que si une journée n'est pas entièrement passée, le NSDateComponent "day" donne 0. Et je me retrouve avec des résultats faux parce que trop précis.


 


Une idée pour qu'un événement prévu pour le 15.1.2014, enregistré à  n'importe quelle heure, soit indiqué "aujourd'hui" le 15.1.2014, à  n'importe heure?


 


Merci. Moi, je vais me coucher, je n'arrive à  rien de toute façon.


Mots clés:

Réponses

  • AliGatorAliGator Membre, Modérateur
    La bonne piste est bien NSDateComponents.

    - Soit tu réduis toutes tes dates à  des NSDateComponents ne contenant que day, month, year, et tu les compares quand tu veux savoir si on tombe aujourd'hui
    - Soit tu utilises NSDateComponents pour demander la différence entre 2 dates, réduite seulement à  l'unité "day"

    ---

    PS : Il est normal que ta solution avec NSDateFormatter te donne le 4/01 à  23h00, c'est parce que tu convertis ta date en NSString ce qui la réduit à  "05.01.2014", et ensuite tu la reconvertis en date, ce qui donne "le 05/01/2014 à  minuit heure locale" (et comme on est en GMT+1, minuit heure locale c'est 23h la veille heure GMT "+0000": "2014-01-04 23:00:00 +0000" est bien exactement la même date que "2014-01-05 00:00:00 +0100" qui correspond à  minuit dans notre fuseau horaire).

    Il aurait fallu forcer la propriété "timeZone" de ton NSDateFormatter à  la timezone GMT, pour que ça t'affiche "2014-01-04 00:00:00 +0000" comme je suppose tu t'attendais à  avoir. Mais comme je l'ai déjà  plusieurs fois expliqué sur divers sujet, ça ne serait pas forcément une bonne idée car ça serait détourner les choses et altérer ta date juste pour qu'à  l'affichage cette timezone qui t'embête semble disparaà®tre, alors qu'en fait ce n'est pas la même heure... donc ça aurait été une mauvaise idée

    En France / Dans le fuseau horaire de Paris, la journée du 5 janvier " comme toutes les journées de l'année où l'heure d'hiver est appliquée " commence à  minuit GMT+1, autrement dit à  23h GMT, puisque quand il est minuit chez nous il est 23h à  Greenwich, mais ça change rien c'est le même moment dans le temps. Si ta question c'est "est ce que cette date tombe aujourd'hui" je suppose que tu poses la question pour quelqu'un qui vit dans ton fuseau horaire, donc pour qui les journées commencent à  minuit GMT+1 (donc 23h GMT en heure d'hiver) et se terminent à  23h59 GMT+1 (donc 22h59 GMT en heure d'hiver).
    Ton code donne donc bien la bonne heure de début de la journée du 5 en France.
  • berfisberfis Membre
    janvier 2014 modifié #3


    La bonne piste est bien NSDateComponents.


    - Soit tu réduis toutes tes dates à  des NSDateComponents ne contenant que day, month, year, et tu les compares quand tu veux savoir si on tombe aujourd'hui

    - Soit tu utilises NSDateComponents pour demander la différence entre 2 dates, réduite seulement à  l'unité "day"




     


    J'ai essayé trois méthodes différentes, mais aucune n'a fonctionné. Je peux certes savoir si on tombe aujourd'hui, mais savoir si on est avant ou après m'entraà®ne à  des calculs assez compliqués. J'ai vu de tout sur SO, particulièrement des gens qui travaillent avec des "magical numbers" et qui alignent trente lignes de code pour calculer des différences entre deux jours...


     


    Et la différence entre deux dates en se basant sur l'unité "day" ne fonctionne pas non plus, puisque entre le 10 janvier à  13h et le 11 janvier à  12h, le calcul renvoie zéro, ce qui est correct mais ne m'arrange pas.


     




    Il aurait fallu forcer la propriété "timeZone" de ton NSDateFormatter à  la timezone GMT, pour que ça t'affiche "2014-01-04 00:00:00 +0000" comme je suppose tu t'attendais à  avoir. Mais comme je l'ai déjà  plusieurs fois expliqué sur divers sujet, ça ne serait pas forcément une bonne idée car ça serait détourner les choses et altérer ta date juste pour qu'à  l'affichage cette timezone qui t'embête semble disparaà®tre, alors qu'en fait ce n'est pas la même heure... donc ça aurait été une mauvaise idée




     


    Que j'aie eu évidemment, je suis allé bricoler du côté des timeZones quand j'ai vu que la différence était pile d'une heure. Mais je commençais à  aligner du code, et encore du code, pour un truc trop simple pour justifier ça. En quelques années de cocoa, je me suis rendu compte que quand ça devient alambiqué et que je commence à  tâtonner dans le noir, c'est que j'ai quitté la bonne piste.


     


    Je m'en suis sorti (eh oui, dormir sur un échec, ça ne me réussit pas) avec ce bricolage "à  l'ancienne" qui a l'avantage de fonctionner, même si, j'avoue, c'est assez laid:



    long diff = [self normalized:testedDate] - [self normalized:[NSDate date]];

    Quant à  la fonction, dont j'ai failli faire une catégorie sur NSDate avant de renoncer, la voici:



    - (long) normalized: (NSDate*)aDate
    {
    NSDateComponents *comps = [ [NSCalendar currentCalendar] components:NSYearCalendarUnit | NSMonthCalendarUnit | NSDayCalendarUnit fromDate: aDate];
    return comps.year*10000 + comps.month*100 + comps.day;
    }

    Si diff = 0, on est sur le jour correct, si diff<0 on est avant, si diff>0 on est après. Je finis par bien faire appel aux NSDatecomponents, mais je me débarrasse de la "fraction de jour". Il y aurait sûrement moyen de faire plus élégant, mais les neurones c'est comme les freins, quand ça chauffe ça perd son efficacité...


     


    En tout cas, merci pour la réponse!


  • AliGatorAliGator Membre, Modérateur
    janvier 2014 modifié #4
    Ma solution : créer une catégorie sur NSDate avec une méthode "compare:components:" (à  l'image des "compare:options:" de NSString & co), qui prend une date à  comparer et le masque des composants à  comparer (qui dans ton cas sera le masque année+mois+jour mais autant faire une méthode générique).

    La méthode va décomposer les 2 dates en NSDateComponents, et comparer chaque unité de la plus grande à  la plus petite (year, month, day, hour, minute, second).
    • On saute/ignore les composants qui ne font pas partie du masque passé en paramètre
    • Dès qu'un composant " parmi ceux faisant partie du masque passé en paramètre donc " est différent entre les 2 dates, on retourne tout de suite (NSOrderedAscending ou NSOrderedDescending selon celui des 2 qui est plus grand que l'autre).
    • Si on arrive à  la fin et qu'on a comparé sur les 2 dates chacun des composants du masque, sans trouver de différence sur aucun, alors tous les composants sont identiques et on retourne donc NSOrderedSame.
    Comme on répète le même code pour chacune des 6 unités qu'on va tester, j'ai utilisé une petite macro qui prend en paramètre le flag + le nom de la propriété de NSDateComponents correspondante à  tester, c'est plus simple
     

    @interface NSDate (OHCompare)
    - (NSComparisonResult)compare:(NSDate*)otherDate components:(NSUInteger)components;
    @end

    @implementation NSDate (OHCompare)

    - (NSComparisonResult)compare:(NSDate*)otherDate components:(NSUInteger)components
    {
    NSDateComponents* comps1 = [[NSCalendar currentCalendar] components:components fromDate:self];
    NSDateComponents* comps2 = [[NSCalendar currentCalendar] components:components fromDate:otherDate];

    NSUInteger p1, p2;
    #define testComponent(compFlag, propName) \
    if (components & compFlag) { \
    p1 = [comps1 propName]; \
    p2 = [comps2 propName]; \
    if (p1 != p2) return (p1<p2) ? NSOrderedAscending : NSOrderedDescending; \
    }

    testComponent( NSYearCalendarUnit, year);
    testComponent( NSMonthCalendarUnit, month);
    testComponent( NSDayCalendarUnit, day);
    testComponent( NSHourCalendarUnit, hour);
    testComponent( NSMinuteCalendarUnit, minute);
    testComponent( NSSecondCalendarUnit, second);
    return NSOrderedSame;
    }
    @end
    C'est plus propre et plus générique que tes multiplications par 10000 et par 100, non ? ;)


    Utilisation:
    - (void)testDateCompare
    {
    NSDate* date1 = [NSDate date]; // maintenant
    NSDate* date2 = [date1 dateByAddingTimeInterval:3600]; // 1h plus tard
    NSUInteger comps = NSYearCalendarUnit | NSMonthCalendarUnit | NSDayCalendarUnit;
    NSComparisonResult res = [date1 compare:date2 components:comps]; // comparer juste année/mois/jour
    NSLog(@comparison result: %d, res);
    }
    Résultat :
    • Quand j'exécute ce bout de code alors qu'il est 23h45, et donc que date1 est encore le 5 janvier mais que date2 est le 6 janvier, il me retourne NSOrderedAscending, autrement dit "date1 est avant date2 si on ne prend en compte que la date dans les heures".
    • Si j'exécute ce bout de code dans 20 minutes, quand on sera passé en France à  la date du 6 janvier, date1 et date2 tomberont donc tous les 2 le même jour et le code me retournera NSOrderedSame, autrement dit "date1 et date2 sont égaux si on ne considère pas les heures" (donc ils sont le même jour, quoi).
  • berfisberfis Membre
    janvier 2014 modifié #5

    AliGator,


     




    Ton code donne donc bien la bonne heure de début de la journée du 5 en France.




     


    J'avais les yeux rivés sur mes NSLog, ce qui m'a empêché de voir qu'une fois installée dans un TextField, ma date était correcte! Mais à  ma décharge, ce n'est pas évident de voir côte à  côte, dans la console :


     


    2014-01-06 18:55:15.518 NormalizedDates[16159:303] 2014-01-05 23:00:00 +0000


     

    et dans le TextField :

     

    lundi, 6 janvier 2014 00.00:00

     

    Je me suis donc énervé pour rien...

     

    Maintenant, je stocke un certain nombre de dates sur lesquelles je fais pas mal de comparaisons, et ma "granularité" est le jour, je tiens tout de même à  "ramener" toutes mes dates à  minuit pour effectuer des comparaisons qui tiennent la route.

     

    Dès lors, et en laissant la TimeZone tranquille, pourquoi ne pas ajouter cette catégorie à  NSDate:

     



    + (NSDate*) normalized: (NSDate*)aDate
    {
    NSCalendar *cal = [NSCalendar currentCalendar];
    NSDateComponents *comps = [cal components:NSYearCalendarUnit | NSMonthCalendarUnit | NSDayCalendarUnit fromDate:aDate];
    [comps setHour:0];
    [comps setMinute:0];
    [comps setSecond:0];
    NSDate *atMidnight = [cal dateFromComponents:comps];
    return atMidnight;
    }

    et l'utiliser comme ça:



    self.today = [NSDate normalized:[NSDate date]];

    J'ai fait le petit test suivant qui semble marcher (voir image).


     



    Pour la petite histoire, tout le programme administratif du canton (=département chez nous) s'est planté en début d'année... pour un problème de date. C'est donc loin d'être bénin...

     

    EDIT : Non, je renonce, ta méthode est bien plus élégante et surtout je peux utiliser les données de mes documents telles quelles, pas besoin de "renormaliser" tout ça.

  • AliGatorAliGator Membre, Modérateur
    Comme je l'ai déjà  expliqué à  plusieurs reprises dans un autre thread, il faut bien comprendre qu'une NSDate ne représente pas une date stricto sensu au sens où on l'entend (un jour/mois/année et une heure/minute/seconde) mais plutôt "un moment précis dans le temps".

    La distinction est que un "moment précis dans le temps" correspond au même moment partout sur la planète. C'est un point sur la ligne du temps qui s'écoule.

    Si tu vis à  Paris et que tu appelles ton pote en Californie, quand tu vas décrocher le téléphone, ta montre indiquera 18h alors que celle de ton correspondant quand il va te répondre indiquera 10h, mais n'empêche que c'est exactement le même moment dans le temps (vous attendez pas 8h entre une question que tu lui poses et sa réponse à  l'autre bout du fil! :D).

    La notion de représentation d'une date sous forme d'une heure, minute et secondes est, elle, attachée/propre à  un calendrier et à  une timezone. Si tu raisonnes dans le calendrier chinois, ou encore dans le calendrier hébreux ou le calendrier Maya, l'année actuelle n'est pas du tout 2014. Si tu es en Californie, le passage d'un lundi au mardi qui le suit ne va pas se produire au même moment que le passage du même lundi au mardi à  Paris.
    Toutes ces notions sont liées au calendrier et au fuseau horaire, éléments qu'une NSDate ne porte pas, car une NSDate représente un moment dans le temps n'importe où sur le globe (et même dans l'Univers) et n'a donc pas de propriété qui reflète un jour ou une heure qui lui serait associé, car ce "moment précis dans le temps" tombe peut-être un lundi dans un NSCalendar, un mardi dans un autre NSCalendar... et y'a peut-être même des NSCalendar dans lesquels la notion de jour n'est pas la même (un calendrier avec des semaines de 10 jours ? Qui sait)

    La représentation "lundi 6 janvier 2014 à  20h30 heure de Paris" n'est qu'une représentation d'une NSDate dans un calendrier et un fuseau horaire donné. Une représentation parmi d'autres représentations possibles de ce même "moment précis dans le temps", de cette même "NSDate" " comme par exemple l'autre représentation "mardi 7 janvier 2014 à  3h30 AM heure de Pékin" qui représente aussi exactement la même NSDate. Ce ne sont que des représentations, un peu comme "1000", "1 000" "mille" et "one thousand" sont 4 représentations différentes de la même quantité.

    ---

    Tout ça pour dire que "ramener toutes tes NSDate à  minuit" ça n'a aucun sens / aucune utilité, car cette notion de "minuit" dépend du calendrier et de sa timeZone. Pire encore, si tu fais cette conversion à  plusieurs moments dans ton application, et qu'entre les 2 moments où tu appelles ta méthode "normalized", le currentCalendar a changé " ce qui peut tout à  fait arriver, soit parce que tu voyages et que tu as changé de fuseau horaire, soit parce qu'on vient de passer de l'heure d'hiver à  l'heure d'été et donc de GMT+1 à  GMT+2, ou même parce que tu as changé tes réglages Régionaux dans tes préférences de ton iPhone... " du coup tu vas avoir des dates qui "ne seront pas ramenées au même minuit" puisqu'entre les 2 appels la notion de "minuit" se sera décalé (changement de timezone) et sera différente...
    A la limite si tu ramènes toutes tes dates à  "minuit dans le fuseau horaire GMT du calendrier grégorien", imposant ainsi un calendrier et une timezone donnés, tu contournerais le problème, même si ça reste un peu alambiqué quand même.

    - Sois tu manipules des instants précis dans le temps (donc pas "minuit le 6 janvier" car cette notion dépend d'une TimeZone et ne représente pas le même instant partout dans le monde, mais plutôt une notion comme "là  maintenant tout de suite" ou "là  pile au moment où tel événement a eu lieu " comme la réception d'un message ou autre), alors NSDate est tout indiqué.
    - Soit tu manipules des représentations de jour/mois/année, de façon totalement indépendante d'un calendrier ou d'une TimeZone, et rien de plus, dans ces cas-là  il y a peut-être + de sens de manipuler juste des NSDateComponents. Mais ce genre de cas est très rare.

    Par exemple si tu fais une application de calendrier, même si tu vas certes représenter une grille avec des jours et des mois, et marquer en rouge la case représentant "aujourd'hui" (ou plutôt devrais-je dire "la case représentant le jour où l'on est actuellement dans la timezone et le calendrier actuellement choisi dans les réglages de l'iPhone"), les évènements seront associés à  des NSDate. Si tu mets ton iPhone dans le fuseau horaire de Paris et crée un évènement pour ce soir à  22h, il va bien tomber dans la case d'aujourd'hui Lundi. Si ensuite tu changes les réglages de ton iPhone pour le mettre dans le fuseau horaire de Pékin, cet évènement va apparaà®tre dans ton calendrier dans la case de Mardi à  5h du matin, puisque tu seras à  l'heure de Pékin. Et c'est la case Mardi qui sera représentée en rouge indiquant la notion de "aujourd'hui". Mais pourtant l'évènement va toujours avoir lieu au même moment, c'est à  dire dans un peu plus d'1h, dans les 2 cas.
    La NSDate associée à  l'évènement représente le moment précis dans le temps universel où va se dérouler cet évènement ("là  maintenant tout de suite", "dans 1h", "il y a 5h", ...), mais pour représenter cette NSDate à  l'écran on est bien obligé de l'associer à  un calendrier et une TimeZone pour représenter ce moment précis dans une représentation donnée, dépendante des réglages de ton iPhone (langue, fuseau horaire, calendrier), ça ne reste qu'une représentation, tout comme le fait qu'on soit lundi ou mardi reste une représentation d'un moment dans le temps qui dépend du fuseau horaire.
  • Eh bien ! Je suis édifié ! Et je comprends mieux les errements rencontrés sur les sites, et les gorges chaudes sur le "bug de l'an 2000"...


     


    En tout cas, maintenant que je vois comment ta catégorie fonctionne " et m'a fait renoncer à  la mienne, heureusement, je n'étais pas très sûr que ce soit une bonne idée, en fait " je l'ai intégrée et testée massivement, avec bonheur. L'appel et le paramètre de retour sont exactement ce que je cherchais, no more magical numbers, et pour le reste (mise en forme, dates relatives, etc) Cocoa fait de l'excellent boulot, comme d'habitude.


     


    Le seul point qui me chiffonne est la vision newtonienne du temps qu'a NSDate... Que dirait Einstein d'un événement qui se produirait en même temps partout dans l'Univers? Heureusement qu'il n'est pas là  pour venir compliquer encore les choses...


     


    En tout cas, merci, par de "bug du passage à  l'heure d'été" à  craindre, grâce à  toi!


  • colas_colas_ Membre
    janvier 2014 modifié #8

    Cette difficulté vient peut-être du fait suivant :


     


    La relation "x est le même jour que x'" n'est pas symétrique.


     


    Autrement dit, si la date X est du même jour que X' (dans le référentiel de X')


    on n'a pas forcément que X' est du même jour que X (dans le référentiel de X).


     


    Contre-exemple : 


     


    d1 = 01h45 à  Paris


    d2 = 11h45 à  Cupertino


     


    d2 est le même jour que d1 (dans le référentiel de d1, c'est-à -dire Paris), car d2 correspond à  19h45 à  Paris.


    MAIS


    d1 n'est pas le même jour que d2 (dans le référentiel de d2, c'est-à -dire de Cupertino), car d1 correspond à  -7h45 à  Cupertino, c'est-à -dire la veille à  17h45.


     


    Voilà  un petit éclairage mathématique ;-)


     


    Il faudrait donc implémenter une méthode comme :


     


    - doDates:(NSDate *) d1 and:(NSDate *) d2 happenTheSameDayInZone:(NSTimeZone *)theZone


  • AliGatorAliGator Membre, Modérateur
    Tout à  fait, et pour compléter la réponse de colas2 pour être précis cela dépend de la timezone mais aussi du NSCalendar (qui porte déjà  l'information de timezone) comme je l'ai expliqué plus haut.

    Or là  ma méthode utilise le currentCalendar, autrement dit le calendrier courant choisi dans les réglages de l'iPhone, et donc également la TimeZone actuellement sélectionnée sur l'iPhone. Donc il fait la comparaison des dates obligatoirement dans le référenciel du calendrier courant de l'iPhone avec mon code, donc dans la timezone courante (celle de Paris par exemple si ton iPhone est réglé sur le fuseau horaire de Paris)

    ---

    En fait je n'aurai pas dû faire une catégorie sur NSDate, et utiliser dans mon code [NSCalendar currentCalendar] systématiquement, mais passer le NSCalendar à  utiliser en paramètre, ou mieux, faire une catégorie sur NSCalendar plutôt que sur NSDate, avec une signature du genre :
    @interface NSCalendar (OHCompare)
    - (void)compareDate:(NSDate*)date1 withDate:(NSDate*)date2 components:(NSUInteger)components __atribute__((nonnull(1,2)));
    @end
    Comme ça on appelle la méthode de comparaison sur un NSCalendar donné (choisi par celui qui appelle la méthode) et du coup il n'y a pas d'ambiguà¯té. Si tu appelles ensuite "[[NSCalendar currentCalendar] compareDate:date1 withDate:date2 components:NSDayCalendarUnit]" ça va comparer le n° du jour de date1 et date2... dans le calendrier courant et donc la timezone courante. Si tu utilises un NSCalendar dont tu règles la timeZone (méthode "-[NSCalendar setTimeZone:]") à  la Californie, tu auras une comparaison dans cette timezone-là  et du coup potentiellement un résultat différent.
  • samirsamir Membre


    , ou mieux, faire une catégorie sur NSCalendar plutôt que sur NSDate, avec une signature du genre :



    @interface NSCalendar (OHCompare)
    - (void)compareDate:(NSDate*)date1 withDate:(NSDate*)date2 components:(NSUInteger)components __atribute__((nonnull(1,2)));
    @end



     


    D'ailleurs la méthode est ajoutée sous iOS 7à  NSCalendar.



    - (NSComparisonResult)compareDate:(NSDate *)date1 toDate:(NSDate *)date2 toUnitGranularity:(NSCalendarUnit)unit NS_AVAILABLE(10_9, __NSCALENDAR_COND_IOS_7_0);
  • Just my 2 cents


    Avant existait la classe NSCalendarDate qui servait justement à  tout ça et qui a été dépréciée avec l'arrivée de NSDateComponents. Je l'ai souvent regrettée et finalement j'ai ajoutée une catégorie à  NSDate avec une fonction - (NSDate*)floorDate qui fait exactement ce que tu as fait avec normalized.


    L'avantage d'une catégorie c'est que tu peux l'importer partout..


Connectez-vous ou Inscrivez-vous pour répondre.