TweetFollow Us on Twitter

The Road to Code: Nobody's Perfect

Volume Number: 24
Issue Number: 11
Column Tag: The Road to Code

The Road to Code: Nobody's Perfect

Document Change Count and Undo

by Dave Dribinr

The last session of The Road to Code left us with a document-based application that used controllers with Cocoa bindings to hook the UI up to our model. Unfortunately, we have a lingering issue. Typical Mac OS X applications track changes to the user's documents and mark them as "dirty" when he makes changes. Thus, when the user tries to close the window or quit the application, you get a warning sheet about unsaved changes along with the chance to save the document. The red close button in the window bar shows documents that are dirty by placing a dot in the red button, as shown in Figure 1.


Figure 1: "Dirty" Dot in Close button

Typical Mac OS X applications also allow users to undo and redo their changes. Nobody is perfect, and everyone likes the ability to change his or her mistakes.

Unfortunately, our application does not behave this way. Users are able to change the width and height of existing rectangles or add and remove rectangles, and then close the window or quit the application with nary a "Do you want to save?" warning. On the plus side, Apple's document-based architecture provides hooks for us to manage the state of the document and provide undo support.

The Document Change Count

As a starting point, I have included, in Listing 1 and Listing 2, our MyDocument class from last month's article that used Cocoa bindings. Our changes will be centered on this class.

Listing 1: MyDocument.h

#import <Cocoa/Cocoa.h>
@interface MyDocument : NSDocument
{
    NSMutableArray * _rectangles;
}
@property (readwrite, copy) NSMutableArray * rectangles;
@end
Listing 2: MyDocument.m
#import "MyDocument.h"
#import "Rectangle.h"
@implementation MyDocument
@synthesize rectangles = _rectangles;
- (id)init
{
    self = [super init];
    if (self == nil)
        return nil;
    
    _rectangles = [[NSMutableArray alloc] init];
    
    return self;
}
- (NSString *)windowNibName
{
    // Override returning the nib file name of the document
    // If you need to use a subclass of NSWindowController or if your document supports multiple NSWindowControllers, 
    //you should remove this method and override -makeWindowControllers instead.
    return @"MyDocument";
}
- (void)windowControllerDidLoadNib:(NSWindowController *) aController
{
    [super windowControllerDidLoadNib:aController];
    // Add any code here that needs to be executed once the windowController has loaded the document's window.
}
- (NSData *)dataOfType:(NSString *)typeName
                 error:(NSError **)outError
{
    NSData * rectangleData =
    [NSKeyedArchiver archivedDataWithRootObject:_rectangles];
    return rectangleData;
}
- (BOOL)readFromData:(NSData *)data
              ofType:(NSString *)typeName
               error:(NSError **)outError
{
    _rectangles = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    return YES;
}
@end

Before we proceed, we should formally go over some terms. When a document can be safely closed without losing any changes it is called a clean document. Once the user starts editing a document, it must be saved or changes will be lost, and the document is called a dirty document. The NSDocument keeps track of whether the document is clean or dirty. The isDocumentEdited method returns YES if the document is dirty and NO if the document is clean. If you do nothing it always returns NO, which is why our document can be closed without prompting the user to save. To change the document state, you have to use the updateChangeCount: method. Thus, in your NSDocument subclass, you would have code that looks like this every time the user edits the document:

    [self updateChangeCount:NSChangeDone];

Why every time? Well, the document's edited state is actually implemented as a count called a change count. The document is clean when the change count is zero, and it's dirty when the change count is greater than zero. Why use a change count, rather than just a simple BOOL flag? This is useful once we talk about undo. For example, if the user makes five edits on a clean document, it becomes dirty. But if the user then runs undo five times, the document should be clean again. By implementing the document status as a change count, this is relatively easy: edits increment the change count and undos decrement the change count. We will be covering undo later in this article.

To increment the change count, we pass the NSChangeDone argument to the updateChangeCount: method, as I noted above. So to make sure our document is marked dirty, we just need to increment the change count any time the user makes an edit. Sounds easy, right? Well, it turns out to be relatively easy, but not trivial. Before we start updating the change count, let's list out all the possible ways a user can edit a rectangle document:

  • Add a new rectangle,
  • Remove an existing rectangle,
  • Change the width of an existing rectangle, or
  • Change the height of an existing rectangle.

Before using Cocoa bindings, these edits went through our document subclass, either through button actions or the table data source. Thus, without bindings, you would update the change count at these places in your document subclass. However, with Cocoa bindings, the array controller now handles all these edits, which actually complicates things a little.

Let's start with marking the document as dirty when adding and removing rectangles. Our MyDocument class has a rectangles property that is of type NSMutableArray. The array controller is bound to this property, so whenever rectangles are added and removed, it uses the setter of this property. Currently, the setter of this property is generated for use with the @synthesize keyword; however, if we write our own setter, we can insert code to update the change count:

- (void)setRectangles:(NSMutableArray *)rectangles
{
    if (rectangles == _rectangles)
        return;
    
    _rectangles = [rectangles copy];
    [self updateChangeCount:NSChangeDone];
}

The last line is the important line that marks the document as edited. If you run it now, the document will be marked as dirty as soon as you add a new rectangle.

However, the document will not be marked as dirty if you change the width or height of an existing rectangle. In order to see this incorrect behavior, you will have to save a document, which will mark the document as clean, and then change a rectangle.

To fix this, we need to handle the last two kinds of edits. When a width or height changes, it goes through the array controller, so you may think that subclassing NSArrayController would be the way to update the change count. It turns out that this is not the best way to implement the change count update, as NSArrayController is not meant to be subclassed for this purpose.

Key-Value Observing

Thankfully, there is another way. We briefly talked about key-value observing or KVO in a previous article, but now we are going to use it in earnest. KVO allows one object to observe changes of another object, thus we can use KVO to watch for changes to existing rectangles. KVO is based on key paths. An object starts observing a key path on a target object and gets notified whenever that key path changes. We need to setup MyDocument as an observer for the "width" and "height" key path of all rectangle objects in the _rectangles array. Let's start off by creating a method in MyDocument that sets itself up as an observer to a single rectangle:

- (void)startObservingRectangle:(Rectangle *)rectangle
{
    [rectangle addObserver:self
                forKeyPath:@"width"
                   options:0
                   context:&kRectangleEditContext];
    [rectangle addObserver:self
                forKeyPath:@"height"
                   options:0
                   context:&kRectangleEditContext];
}

Since each KVO registration is for a single key path, we have to observe each key path independently. We do not need any options, but we are using the context. The kRectangleEditContext is a static variable we need to set at the top of our source file:

static NSString * kRectangleEditContext = @"Rectangle Edit";

The context is a void * value and is passed in the KVO notifications so you discern between multiple observers. Remember that void * is a pointer to anything. It does not even have to be an Objective-C object, but we are using a pointer to an NSString * because it makes it easy to create a unique pointer value. We are using the same context because we want to perform the same action when either the "width" or "height" changes. Next, we have to implement the method that gets called when an observed key path changes:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (context == &kRectangleEditContext)
    {
        [self updateChangeCount:NSChangeDone];
    }
    else
    {
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                              context:context];
    }
}

All KVO notifications go through this one method. This is different than NSNotification-based notifications, where you can choose a method for notification delivery, and this is why the context is so important. Here, we compare the context against the address of kRectangleEditContext we used in to addObserver:.... If it is that context, we update the change count to mark the document as dirty. It is important to call your superclass's implementation if you do not handle the KVO notification in case it needs to handle its own KVO notifications.

We also need to create a method to unregister for KVO notifications, which we will use when a rectangle is removed. It's important to always remove yourself as an observer otherwise you may get runtime errors.

- (void)stopObservingRectangle:(Rectangle *)rectangle
{
    [rectangle removeObserver:self
                   forKeyPath:@"width"];
    [rectangle removeObserver:self
                   forKeyPath:@"height"];
}

We now have our KVO infrastructure in place, but we are not yet observing any rectangles. The place to do this is again in the setRectangles: setter. This method gets called with a whole new array of rectangles even if a single rectangle is added or removed. The behavior we want is to stop observing all rectangles in the old array and then start observing all rectangles in the new array. Here's what that looks like:

- (void)setRectangles:(NSMutableArray *)rectangles
{
    if (rectangles == _rectangles)
        return;
    
    for (Rectangle * rectagle in _rectangles)
        [self stopObservingRectangle:rectagle];
    
    _rectangles = [rectangles copy];
    for (Rectangle * rectagle in _rectangles)
        [self startObservingRectangle:rectagle];
    
    [self updateChangeCount:NSChangeDone];
}

So, before setting the _rectangles instance variable to the new array, we loop through all rectangles and stop observing them. And once we set the _rectangles instance variable, we start observing all these rectangles. Finally, we update the change count, as we did earlier, so that adding and removing of rectangles causes the change count to increment.

We have one final detail to cover in our program. If we open a saved document we still do not properly mark that the document is dirty after modifying a width or height. The problem is that our readFromData:... method is setting the _rectangles instance variable directly. This bypasses our setRectangles: accessor, so we never start to observe the saved rectangles. The solution is to use the setter by assigning via property dot notation:

- (BOOL)readFromData:(NSData *)data
              ofType:(NSString *)typeName
               error:(NSError **)outError
{
    self.rectangles = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    return YES;
}

Remember, using property dot notation is the same as calling the setter method. We're not quite done, yet. The setter marks the document as dirty, which means that a document will be immediately marked as dirty after opening. This is not desired, so the best way is to set the change count to zero after setting the rectangles property:

- (BOOL)readFromData:(NSData *)data
              ofType:(NSString *)typeName
               error:(NSError **)outError
{
    self.rectangles = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    [self updateChangeCount:NSChangeCleared];
    return YES;
}

The NSChangeCleared argument to updateChangeCount: causes the change count to be reset to zero, and thus marking the document as clean.

At this point, our application now properly tracks the document state. If we edit any width or height, it triggers a KVO notification, which in turn updates the change count. If we add or remove a rectangle, it also triggers an update to the change count. Note that saving a document or reverting to saved automatically sets the change count to zero and marks the document as clean.

Just because it works, however, does not mean we cannot improve things a bit. The one issue we are going to fix is the fact that the setRecangles: setter is used even if just a single rectangle is added or removed. This is fine if the number of rectangles in our array is small, but it can become a problem, as the array gets larger. While I'm not a big fan of optimizing before profiling (a technique to find slow portions of code), I'm going to show you how you could alleviate the problem if you need to.

Indexed Accessors

Instead of the array controller passing a whole new array every time a rectangle is added or deleted, wouldn't it be nice if it could tell you that a single rectangle has been added or deleted? It turns out that KVC already has the answer for us, and we just need to use it.

Most properties represent a single value, for example the width of a rectangle or the name of a person. Because it is a single value, it is also known as a to-one relationship. When a property represents multiple values, as is the case with the "rectangles" property of our MyDocument class, it is known as a to-many relationship. This is because for every one MyDocument object, there are many rectangles. For to-one relationships, the standard KVC method naming convention of -<key> and -set<Key> work just fine. For to-many relationships, there are optional methods you can implement for more efficient behavior called indexed accessors. On the getter side you have two new indexed getters:

-countOf<Key>
-objectIn<Key>AtIndex

These two methods allow you to get the number of objects in the to-many relationship or a single object without having to fetch the whole array. Here's how we would implement indexed getters for the "rectangles" property:

- (NSUInteger)countOfRectangles
{
    return [_rectangles count];
}
- (Rectangle *)objectInRectanglesAtIndex:(NSUInteger)index
{
    return [_rectangles objectAtIndex:index];
}

We implement these using the array's direct access API. While these are nice, it does not really help us with our problem. The are also two indexed accessor methods on the setter side:

-insertObject:in<Key>AtIndex:
-removeObjectFrom<Key>AtIndex:

Again, to implement these for the "rectangles" property, we would use the array API:

- (void)insertObject:(Rectangle *)rectangle
 inRectanglesAtIndex:(NSUInteger)index
{
    [_rectangles insertObject:rectangle atIndex:index];
}
- (void)removeObjectFromRectanglesAtIndex:(NSUInteger)index
{
    [_rectangles removeObjectAtIndex:index];
}

The NSArrayController is smart enough to use these methods to add or remove a single rectangle, if they exist. Of course, these methods still do not update the change count, but that's easy to add. We must additionally make sure to start and stop observing these rectangles, as well:

- (void)insertObject:(Rectangle *)rectangle
 inRectanglesAtIndex:(NSUInteger)index
{
    [self startObservingRectangle:rectangle];
    [_rectangles insertObject:rectangle atIndex:index];
    [self updateChangeCount:NSChangeDone];
}
- (void)removeObjectFromRectanglesAtIndex:(NSUInteger)index
{
    Rectangle * rectangleToRemove = [_rectangles objectAtIndex:index];
    [self stopObservingRectangle:rectangleToRemove];
    [_rectangles removeObjectAtIndex:index];
    [self updateChangeCount:NSChangeDone];
}

And voila! With two simple methods, we have optimized setters while keeping the ability to update the change count. We still want to keep the setRectangles: method for replacing all rectangles in one call. This is handy when opening a document, for example.

One nice thing about indexed accessors is that it allows you to implement to-many relationships without using an array internally. While it certainly is easiest to implement indexed accessors with an array, you are by no means required to do so.

Undo and Redo

While changing the document status is a good step towards making our application a standard OS X application, users also expect support for undo. Undo is handled in Cocoa by a class named NSUndoManager. Its purpose is to hold the list of undo and redo actions, and then perform these actions when the user invokes Undo or Redo from the Edit menu.

In order to understand how undo works, we need to dig a bit deeper into Objective-C method dispatching. A few articles back, we talked about selectors and how to use them to send messages. Say, for example, we have a Person class with a method to set the name:

- (void)setName:(NSString *)name;

You typically call this method using the square bracket syntax with which you are now very familiar:

    [person setName:@"Joe"];

Remember, the selector is the name of the method, including any colons, in this case "setName:". To call a method with its selector, you would use one of the performSelector: methods provided by NSObject. Thus, an alternate way to call the setName: method by using its selector is:

    [person performSelector:@selector(setName:)
                 withObject:@"Joe"];

This kind of method calling is called dynamic dispatch because the selector and arguments can be changed dynamically at runtime, instead of statically at compile time. As we also discussed earlier how Cocoa uses dynamic dispatch to invoke the actions of controls, such as when a user presses a button. But the nice thing about selectors and dynamic dispatch is that you have all the components you need to have a sort of freeze-dried method call. You can save the target, selector, and arguments in instance variables, for example, and then invoke the method later.

While the performSelector: methods provided by NSObject are very nice, they do have some limitations. For example, you can only pass up to two arguments and the arguments and return values must be Objective-C objects. You cannot pass primitive arguments or get primitive return values. For that, you have to bring out the big guns: NSInvocation.

NSInvocation is a class that encapsulates an entire method call, the target object, the selector, and all the arguments. It can also handle primitive types. The downside is that it is a bit of a pain to use. For example, let's say we wanted to set the width of a Rectangle using NSInvocation. Remember that properties also generate setter and getter methods, so we can use the setWidth: selector to set the width property. Here's the code:

    SEL selector = @selector(setWidth:);
    NSMethodSignature * signature =
        [rectangle methodSignatureForSelector:selector];
    NSInvocation * invocation =
        [NSInvocation invocationWithMethodSignature:signature];
    [invocation setTarget:rectangle];
    [invocation setSelector:selector];
    float newWidth = 30.0;
    [invocation setArgument:&newWidth atIndex:2];
    [invocation invoke];

Don't worry about fully understanding this code. Yes, the newWidth argument is actually the second argument. The first two arguments, 0 and 1, are always set to the target and selector, respectively. I just want to quickly introduce the NSInvocation object. If you want to full understand it, please consult the documentation.

The reason I bring up invocations is that NSUndoManager uses them under the hood to implement undo and redo actions. In fact, it keeps a stack of invocations for both the undo and redo actions. Say we're starting with a new, clean document and we add a new rectangle and change the width to 30, from the default of 15. Here is the list of actions the user performed, in order:

  • Add a rectangle at index zero.
  • Set the width of rectangle at index zero to 30.
In order to undo these operations, we must perform their inverse actions in reverse order:
  • Set width of rectangle at index zero to 15.
  • Remove rectangle at index zero.

How do you do you add actions to the undo manager in code? There are two ways. If you have a simple method with only a single argument that is an Objective-C type, you can use selectors:

    [undoManager registerUndoWithTarget:person
                               selector:@selector(setName:)
                                 object:@"Joe"];

However, if you need to use primitive types, then you need to use the NSInvocation variant. Thankfully, NSUndoManager allows you to do this without going through the whole rigmarole of creating an NSInvocation object. Here's how you would create an undo action to set the width of a rectangle to 15:

    [[undoManager prepareWithInvocationTarget:rectangle]
        setWidth:15];

Note that even though this looks like it is actually calling setWidth:15, it is only creating an invocation for this method. NSUndoManager is using some deep Objective-C magic to turn what looks like a real method call into an NSInvocation. We don't need to concern ourselves with how it does it (it uses forwardInvocation: under the covers if you do want to learn more), we just need to know that prepareWithInvocation: adds an invocation to the undo stack.

Adding Undo Support

Now that we know how to add actions to the undo manager, we can start modifying our code for undo support. We just need to modify every case where we insert or remove a rectangle or change the width or height of a rectangle. Thankfully, we already went through the process of identifying these locations for dirty document support, and this will help us add undo support. For example, to add an undo action when a rectangle is added, we can use our indexed accessor:

- (void)insertObject:(Rectangle *)rectangle
 inRectanglesAtIndex:(NSUInteger)index
{
    NSUndoManager * undoManager = [self undoManager];
    [[undoManager prepareWithInvocationTarget:self]
        removeObjectFromRectanglesAtIndex:index];
    if (![undoManager isUndoing])
        [undoManager setActionName:@"Add Rectangle"];
    
    [self startObservingRectangle:rectangle];
    [_rectangles insertObject:rectangle atIndex:index];
}

This requires a bit of explanation. First, we get an undo manager from self. NSDocument provides separate undo managers for each document, so each document has their own undo and redo stack.

The next interesting bit is that we can set the name of an action. This shows up in the Edit menu to provide the user with more context about a particular undo action. So instead of just showing Undo it will display Undo Add Rectangle. The only thing to be aware of is that you set the action name after adding an action to the undo manager, not before.

And finally, we no longer need to update the change count. The undo manager takes care of managing the change count for us.

We now need to add undo support to the other rectangle setter methods, in a similar fashion:

- (void)removeObjectFromRectanglesAtIndex:(NSUInteger)index
{
    Rectangle * rectangleToRemove = [_rectangles objectAtIndex:index];
    NSUndoManager * undoManager = [self undoManager];
    [[undoManager prepareWithInvocationTarget:self]
        insertObject:rectangleToRemove
 inRectanglesAtIndex:index];
    if (![undoManager isUndoing])
        [undoManager setActionName:@"Remove Rectangle"];
    
    [self stopObservingRectangle:rectangleToRemove];
    [_rectangles removeObjectAtIndex:index];
}
- (void)setRectangles:(NSMutableArray *)rectangles
{
    if (rectangles == _rectangles)
        return;
   
    for (Rectangle * rectagle in _rectangles)
        [self stopObservingRectangle:rectagle];
    
    NSUndoManager * undoManager = [self undoManager];
    [[undoManager prepareWithInvocationTarget:self]
        setRectangles:_rectangles];
    _rectangles = [rectangles copy];
    for (Rectangle * rectagle in _rectangles)
        [self startObservingRectangle:rectagle];
}

We basically replaced the change count calls with undo action registration. Earlier in this article, when we reset the change count when the document is loaded. Now, we want to do the same thing, but this time, we want to remove all actions from the undo stack:

- (BOOL)readFromData:(NSData *)data
              ofType:(NSString *)typeName
               error:(NSError **)outError
{
    self.rectangles = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    NSUndoManager * undoManager = [self undoManager];
    [undoManager removeAllActions];
    return YES;
}

The last detail is being able to undo individual edits. We used KVO to watch for changes to the width and height of each rectangle, and we can use KVO to support undo as well. The basic plan of attack is to use the KVO notification to add an undo action. The only problem is the KVO notification happens after the key path value has changed. If only we could get the previous value of the key path...

It turns out KVO has this capability. We do have to slightly change the way we start observing objects. Remember when we set the KVO options to 0? We are going to modify that to use the NSKeyValueObservingOptionOld option:

- (void)startObservingRectangle:(Rectangle *)rectangle
{
    [rectangle addObserver:self
                forKeyPath:@"width"
                   options:NSKeyValueObservingOptionOld
                   context:&kRectangleEditContext];
    [rectangle addObserver:self
                forKeyPath:@"height"
                   options:NSKeyValueObservingOptionOld
                   context:&kRectangleEditContext];
}

This tells KVO to supply the old value with the KVO notification. Our KVO notification callback method can now use that to add an undo action:

- (void)changeKeyPath:(NSString *)keyPath
             ofObject:(id)object
              toValue:(id)value
{
    [object setValue:value forKeyPath:keyPath];
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (context == &kRectangleEditContext)
    {
        id oldValue = [change objectForKey:NSKeyValueChangeOldKey];
        
        NSUndoManager * undoManager = [self undoManager];
        [[undoManager prepareWithInvocationTarget:self]
            changeKeyPath:keyPath ofObject:object toValue:oldValue];
        [undoManager setActionName:@"Rectangle Edit"];
    }
    else
    {
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                              context:context];
    }
}

The change dictionary contains the old value. We use this, plus the keyPath and object to register an undo action. It uses a helper method that sets a value using KVC. KVO, like KVC, will automatically convert primitive number values into NSNumber objects. By using KVC, we also can share the implementation for both the width and height properties.

Conclusion

At this point, you should have an application with full dirty document and undo support. Your users will greatly appreciate the extra effort you've made for this bit of polish. If you are having trouble getting this all working, download the accompanying projects from the MacTech website.


Dave Dribin has been writing professional software for over eleven years. After five years programming embedded C in the telecom industry and a brief stint riding the Internet bubble, he decided to venture out on his own. Since 2001, he has been providing independent consulting services, and in 2006, he founded Bit Maki, Inc. Find out more at http://www.bitmaki.com/ and http://www.dribin.org/dave/.

 

Community Search:
MacTech Search:

Software Updates via MacUpdate

Latest Forum Discussions

See All

Summon your guild and prepare for war in...
Netmarble is making some pretty big moves with their latest update for Seven Knights Idle Adventure, with a bunch of interesting additions. Two new heroes enter the battle, there are events and bosses abound, and perhaps most interesting, a huge... | Read more »
Make the passage of time your plaything...
While some of us are still waiting for a chance to get our hands on Ash Prime - yes, don’t remind me I could currently buy him this month I’m barely hanging on - Digital Extremes has announced its next anticipated Prime Form for Warframe. Starting... | Read more »
If you can find it and fit through the d...
The holy trinity of amazing company names have come together, to release their equally amazing and adorable mobile game, Hamster Inn. Published by HyperBeard Games, and co-developed by Mum Not Proud and Little Sasquatch Studios, it's time to... | Read more »
Amikin Survival opens for pre-orders on...
Join me on the wonderful trip down the inspiration rabbit hole; much as Palworld seemingly “borrowed” many aspects from the hit Pokemon franchise, it is time for the heavily armed animal survival to also spawn some illegitimate children as Helio... | Read more »
PUBG Mobile teams up with global phenome...
Since launching in 2019, SpyxFamily has exploded to damn near catastrophic popularity, so it was only a matter of time before a mobile game snapped up a collaboration. Enter PUBG Mobile. Until May 12th, players will be able to collect a host of... | Read more »
Embark into the frozen tundra of certain...
Chucklefish, developers of hit action-adventure sandbox game Starbound and owner of one of the cutest logos in gaming, has released their roguelike deck-builder Wildfrost. Created alongside developers Gaziter and Deadpan Games, Wildfrost will... | Read more »
MoreFun Studios has announced Season 4,...
Tension has escalated in the ever-volatile world of Arena Breakout, as your old pal Randall Fisher and bosses Fred and Perrero continue to lob insults and explosives at each other, bringing us to a new phase of warfare. Season 4, Into The Fog of... | Read more »
Top Mobile Game Discounts
Every day, we pick out a curated list of the best mobile discounts on the App Store and post them here. This list won't be comprehensive, but it every game on it is recommended. Feel free to check out the coverage we did on them in the links below... | Read more »
Marvel Future Fight celebrates nine year...
Announced alongside an advertising image I can only assume was aimed squarely at myself with the prominent Deadpool and Odin featured on it, Netmarble has revealed their celebrations for the 9th anniversary of Marvel Future Fight. The Countdown... | Read more »
HoYoFair 2024 prepares to showcase over...
To say Genshin Impact took the world by storm when it was released would be an understatement. However, I think the most surprising part of the launch was just how much further it went than gaming. There have been concerts, art shows, massive... | Read more »

Price Scanner via MacPrices.net

Amazon is offering a $100 discount on every M...
Amazon is offering a $100 instant discount on each configuration of Apple’s new 13″ M3 MacBook Air, in Midnight, this weekend. These are the lowest prices currently available for new 13″ M3 MacBook... Read more
You can save $300-$480 on a 14-inch M3 Pro/Ma...
Apple has 14″ M3 Pro and M3 Max MacBook Pros in stock today and available, Certified Refurbished, starting at $1699 and ranging up to $480 off MSRP. Each model features a new outer case, shipping is... Read more
24-inch M1 iMacs available at Apple starting...
Apple has clearance M1 iMacs available in their Certified Refurbished store starting at $1049 and ranging up to $300 off original MSRP. Each iMac is in like-new condition and comes with Apple’s... Read more
Walmart continues to offer $699 13-inch M1 Ma...
Walmart continues to offer new Apple 13″ M1 MacBook Airs (8GB RAM, 256GB SSD) online for $699, $300 off original MSRP, in Space Gray, Silver, and Gold colors. These are new MacBook for sale by... Read more
B&H has 13-inch M2 MacBook Airs with 16GB...
B&H Photo has 13″ MacBook Airs with M2 CPUs, 16GB of memory, and 256GB of storage in stock and on sale for $1099, $100 off Apple’s MSRP for this configuration. Free 1-2 day delivery is available... Read more
14-inch M3 MacBook Pro with 16GB of RAM avail...
Apple has the 14″ M3 MacBook Pro with 16GB of RAM and 1TB of storage, Certified Refurbished, available for $300 off MSRP. Each MacBook Pro features a new outer case, shipping is free, and an Apple 1-... Read more
Apple M2 Mac minis on sale for up to $150 off...
Amazon has Apple’s M2-powered Mac minis in stock and on sale for $100-$150 off MSRP, each including free delivery: – Mac mini M2/256GB SSD: $499, save $100 – Mac mini M2/512GB SSD: $699, save $100 –... Read more
Amazon is offering a $200 discount on 14-inch...
Amazon has 14-inch M3 MacBook Pros in stock and on sale for $200 off MSRP. Shipping is free. Note that Amazon’s stock tends to come and go: – 14″ M3 MacBook Pro (8GB RAM/512GB SSD): $1399.99, $200... Read more
Sunday Sale: 13-inch M3 MacBook Air for $999,...
Several Apple retailers have the new 13″ MacBook Air with an M3 CPU in stock and on sale today for only $999 in Midnight. These are the lowest prices currently available for new 13″ M3 MacBook Airs... Read more
Multiple Apple retailers are offering 13-inch...
Several Apple retailers have 13″ MacBook Airs with M2 CPUs in stock and on sale this weekend starting at only $849 in Space Gray, Silver, Starlight, and Midnight colors. These are the lowest prices... Read more

Jobs Board

Relationship Banker - *Apple* Valley Financ...
Relationship Banker - Apple Valley Financial Center APPLE VALLEY, Minnesota **Job Description:** At Bank of America, we are guided by a common purpose to help Read more
IN6728 Optometrist- *Apple* Valley, CA- Tar...
Date: Apr 9, 2024 Brand: Target Optical Location: Apple Valley, CA, US, 92308 **Requisition ID:** 824398 At Target Optical, we help people see and look great - and Read more
Medical Assistant - Orthopedics *Apple* Hil...
Medical Assistant - Orthopedics Apple Hill York Location: WellSpan Medical Group, York, PA Schedule: Full Time Sign-On Bonus Eligible Remote/Hybrid Regular Apply Now Read more
*Apple* Systems Administrator - JAMF - Activ...
…**Public Trust/Other Required:** None **Job Family:** Systems Administration **Skills:** Apple Platforms,Computer Servers,Jamf Pro **Experience:** 3 + years of Read more
Liquor Stock Clerk - S. *Apple* St. - Idaho...
Liquor Stock Clerk - S. Apple St. Boise Posting Begin Date: 2023/10/10 Posting End Date: 2024/10/14 Category: Retail Sub Category: Customer Service Work Type: Part Read more
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.