Ad

Our DNA is written in Swift
Jump

Stuff you learn from reverse-engineering Notes.app

I’m adding note taking to iWoman 2.0 and so I was thinking which metaphor would be one that users would understand and like. So I decided to mimic the look and feel of the built-in Notes app.

There where quite a few interesting things I had to learn and figure out and in this article I am going to share them with you. These are techniques that you can use in many other scenarios besides of making your own Notes view controller.

I was clear from the start that I needed to use a UITextView for the editing itself. Notes.app has several specialities that we have to figure out if we want to capture the look.

  • Font is Marker Felt Thin, Size 19.
  • each line sits on top of a grayish blue horizontal line
  • the text view has a padding at all sides, something that the standard UITextView does not give us
  • There is padding at the top, but still the text goes up to the corner
  • the horizontal lines move together with the text and never end towards the bottom
  • two static vertical brown lines line up with markings at the top and the bottom
  • the body of the notes is not just a yellow gradient, but has some structure and speckles
  • scrolled text disappears behind the images for the top and bottom edge
  • The text view needs to be dynamically resized when the keyboard appears or disappears to prevent hiding of text.

Those where the challenges, in this YouTube video you see my solution and an overview of how I achieved it. More in-depth reasoning you find below.

Setting the font and size for the text view is easy, much harder is to a never-ending sheet with lines to move in synch. My first gut feeling was to simply attach it as a subview to the text view which itself is a descendant of UIScrollView. So it’s possible to insert views as subviews to it and they will get moved together with the contents of the text. If you want to make sure that you don’t disturb the interaction with the text simply add it at index 0, that is behind all views that might be subviews of the scroll view.

Though for this case this approach would not work as we cannot set a padding for the text view. So we have to move the frame of the text view that the text is at a nice distance from the edges. If we add the lines view now we see that it gets clipped at the edges of the text view. Not what we had in mind.

I started out with just a screenshot of the Notes.app where I removed some UI elements, but then I discovered that I needed to draw the lines myself and that I had to split it in three: top edge, bottom edge and body of the note. For the body I had a friend remove the lines from the screenshot with Photoshop.

UITextView is an extended UIScrollView

Since UITextView is a child of UIScrollView all the scrollview delegate methods are also passed to any delegate you might set. So what I did was to simply implement a scrollViewDidScroll method which gets called every time the text view moves. Then I only need to set a transform for the lines view according to the current contentOffset. Having made sure that the top and bottom edge images are on top of the lines view cause them to go underneath those, so I don’t need an extra clipping view to make sure the lines don’t go over the images.

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
	CGPoint offset = scrollView.contentOffset;
	linesView.transform = CGAffineTransformMakeTranslation(0, -offset.y);
}

The lines themselves are just regular UIView where I filled in the drawRect to draw horizontal lines, nothing fancy there. Same is true for the second view I have for the vertical lines. I’ve tried to combine the lines into one view, but there I had the problem when you pull down the text view, the vertical lines would break and a whole would appear at the top. So I settled for two line view: one static for the vertical lines and one moving via transform in tandem with the text view. Obviously both have to be non-opaque and have a transparent background.

The next interesting trick necessary becomes apparent when you actually start editing. The keyboard will cover the lower half of the text view causing text to be hidden. My first idea was to resize it but here you have the option to either guess the animation duration and hight or you google it. I found the optimal solution to be present there, on Stack Overflow.

Resizing in synch with the Keyboard

Whenever the keyboard appears or disappears notifications are sent that also contain a dictionary giving you all relevant animation parameters. You can simply take those and your animation will be perfectly synchronized with the movement of the keyboard. Be it a toolbar which you want to move from the bottom us so that it rides on top of the keyboard. Or something simple as changing a text view’s frame.

So we register for the name:UIKeyboardWillShowNotification and name:UIKeyboardWillHideNotification when our notepad appears on screen and unregister when it disappears.

- (void) viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
 
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(liftMainViewWhenKeybordAppears:) name:UIKeyboardWillShowNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(returnMainViewToInitialposition:) name:UIKeyboardWillHideNotification object:nil];
}
 
- (void) viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    [[NSNotificationCenter defaultCenter] removeObserver:self];
 
	[_textView resignFirstResponder];
}

Then for the real meat we implement our methods that get called when these events occur.

- (void) liftMainViewWhenKeybordAppears:(NSNotification*)aNotification{
    NSDictionary* userInfo = [aNotification userInfo];
    NSTimeInterval animationDuration;
    UIViewAnimationCurve animationCurve;
    CGRect keyboardFrame;
    [[userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey] getValue:&animationCurve];
    [[userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey] getValue:&animationDuration];
    [[userInfo objectForKey:UIKeyboardBoundsUserInfoKey] getValue:&keyboardFrame];
    [UIView beginAnimations:nil context:nil];
    [UIView setAnimationDuration:animationDuration];
    [UIView setAnimationCurve:animationCurve];
    [self.textView setFrame:CGRectMake(self.textView.frame.origin.x, self.textView.frame.origin.y,
        self.textView.frame.size.width, self.textView.frame.size.height - keyboardFrame.size.height + 5.0)];
    [UIView commitAnimations];
}
 
- (void) returnMainViewToInitialposition:(NSNotification*)aNotification{
    NSDictionary* userInfo = [aNotification userInfo];
    NSTimeInterval animationDuration;
    UIViewAnimationCurve animationCurve;
    CGRect keyboardFrame;
    [[userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey] getValue:&animationCurve];
    [[userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey] getValue:&animationDuration];
    [[userInfo objectForKey:UIKeyboardBoundsUserInfoKey] getValue:&keyboardFrame];
    [UIView beginAnimations:nil context:nil];
    [UIView setAnimationDuration:animationDuration];
    [UIView setAnimationCurve:animationCurve];
    [self.textView setFrame:CGRectMake(self.textView.frame.origin.x, self.textView.frame.origin.y,
        self.textView.frame.size.width, self.textView.frame.size.height + keyboardFrame.size.height - 5.0)];
    [UIView commitAnimations];
}

You can see that we are getting our values for animation curve, duration and the frame of the keyboard from values contained in the dictionary that the iPhone is so friendly to provide together with those notifications. Really cool stuff.

iPadding

If you have the textView in full view size then you find that it’s text is too close for comfort to the edges. Now as a scrollview descendant you might have guessed that setting the contentInset might solve this problem, but it does not for horizontal padding. The problem with insetting left or right is that you increase the contentSize causing horizontal scrolling to become possible. But we only want vertical scrolling to occur. That’s why we change the frame of the text view such that there is enough space at the left and right.

Padding DOES however work nicely to inset the text from the top. The text view align with the bottom corner of the top edge image. By setting a contentInset for top we can get the spacing that we want, but still be able to have the text go to the very corner of the virtual page.

We’re almost done, the final challenge appears if you add a couple of lines of text to your notepad. The lines view is not yet resized and so the lines just stop.

Peeking under UITextView’s Shirt

Text views somehow know to dynamically resize the content. So my solution for the resizing problem is to add an observer for changes to the contentSize property of UITextView. This technique is called KVO (key value observing) and you can add such a hook to any object so that every time there is a change some method is informed about it.

So in the loadView, where I am creating my view hierarchy, I have this line to install my KVO hook. I choose to only receive the new value by specifying option NSKeyValueObservingOptionNew. If you add a binary OR with NSKeyValueObservingOptionOld you also get sent the value before the change.

[_textView addObserver:self forKeyPath:@"contentSize" options:NSKeyValueObservingOptionNew context:nil];

The best way to remove the hook is in dealloc. Removing is necessary because otherwise you get a warning on the console that textview was deallocated with a KVO observer still registered.

[_textView removeObserver:self forKeyPath:@"contentSize"];

All KVO observing for simple values is done in observeValueForKeyPath, as shown below. You receive a change dictionary containing all the before and after values that you chose to get.

- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
	CGSize newSize = [[change objectForKey:@"new"] CGSizeValue];
 
	linesView.frame = CGRectMake(linesView.frame.origin.x, linesView.frame.origin.y, newSize.width + 124.0, newSize.height + 480.0);
}

Since there is no visible border for the lines view I don’t need to animate the change. I am making the width and height large enough so that the lines view will aways cover the entire screen even if you drag it up so that the bottom rubberbanding occurs.

Conclusion

I’m not as good as a designer as I am a programmer. If you are like me then it is great for practicing your synthetic skills. By synthetic I mean to find a UI that works and then synthesize it out of what you know. You grow as a skilled programmer by finding out the inherent challenges that you have to figure out so that the whole puzzle and UI experience comes together.

One question left unanswered is if Apple will object to me shamelessly reusing their notepad metaphor. If they reject iWoman 2.0 because of this then it’s should be not too difficult to get a designer to create the 3 images. Maybe I’d like pink notepaper for iWoman anyway, what do you think?

I polished the DTNotePadViewController into a component that I can reuse with any kind of app that requires lined note paper. To be able to do so I needed to add a delegation mechanism for and dual mode behavior so that I present it modally for new notes and via navigation controller for editing existing notes. That’s now here.


Categories: Recipes

10 Comments »

  1. Hey don’t we need to increase the height of linesView with the transform in the following scenario?

    – (void)scrollViewDidScroll:(UIScrollView *)scrollView
    {
    CGPoint offset = scrollView.contentOffset;
    linesView.transform = CGAffineTransformMakeTranslation(0, -offset.y);
    }

  2. Because we are observing the text View Size with KVO

  3. Thanks a lot for this. I added a view with the lines drawn and it works fine. But the line height of the UITextView text seem to change towards the below of the view. The text goes off the lines towards bottom. Gap between the lines seem to be fine. Any idea where I got it wrong?

  4. scrollViewDidScroll: method is not get called during up/down scrolling of TextView. Can u help in this regards ?
    Thanks

  5. it gets called on the scroll view delegate.

  6. hi do u have working xcode project …

  7. I don’t understand how you are drawing the lines. Repeating the same lines of code to draw infinite lines doesn’t make sense, especially with a universal application. How are you doing it? A loop?