Core Text Tutorial for iOS: Making a Magazine App

Learn how to make your own magazine app with custom text layout in this Core Text tutorial for iOS. By Lyndsey Scott.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

A Basic Magazine Layout

If you thought a monthly magazine of Zombie news could possibly fit onto one measly page, you'd be very wrong! Luckily Core Text becomes particularly useful when laying out columns since CTFrameGetVisibleStringRange can tell you how much text will fit into a given frame. Meaning, you can create a column, then once its full, you can create another column, etc.

For this app, you'll have to print columns, then pages, then a whole magazine lest you offend the undead, so... time to turn your CTView subclass into a UIScrollView.

Open CTView.swift and change the class CTView line to:

class CTView: UIScrollView {

See that, zombies? The app can now support an eternity of undead adventures! Yep -- with one line, scrolling and paging is now available.

happy zombie

Up until now, you've created your framesetter and frame inside draw(_:), but since you'll have many columns with different formatting, it's better to create individual column instances instead.

Create a new Cocoa Touch Class file named CTColumnView subclassing UIView.

Open CTColumnView.swift and add the following starter code:

import UIKit
import CoreText

class CTColumnView: UIView {
  
  // MARK: - Properties
  var ctFrame: CTFrame!
  
  // MARK: - Initializers
  required init(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)!
  }
  
  required init(frame: CGRect, ctframe: CTFrame) {
    super.init(frame: frame)
    self.ctFrame = ctframe
    backgroundColor = .white
  }
  
  // MARK: - Life Cycle
  override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }
      
    context.textMatrix = .identity
    context.translateBy(x: 0, y: bounds.size.height)
    context.scaleBy(x: 1.0, y: -1.0)
      
    CTFrameDraw(ctFrame, context)
  }
}

This code renders a CTFrame just as you'd originally done in CTView. The custom initializer, init(frame:ctframe:), sets:

  1. The view's frame.
  2. The CTFrame to draw into the context.
  3. And the view's backgound color to white.

Next, create a new swift file named CTSettings.swift which will hold your column settings.

Replace the contents of CTSettings.swift with the following:

import UIKit
import Foundation

class CTSettings {
  //1
  // MARK: - Properties
  let margin: CGFloat = 20
  var columnsPerPage: CGFloat!
  var pageRect: CGRect!
  var columnRect: CGRect!
  
  // MARK: - Initializers
  init() {
    //2
    columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
    //3
    pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
    //4
    columnRect = CGRect(x: 0,
                        y: 0,
                        width: pageRect.width / columnsPerPage,
                        height: pageRect.height).insetBy(dx: margin, dy: margin)
  }
}
  1. The properties will determine the page margin (default of 20 for this tutorial); the number of columns per page; the frame of each page containing the columns; and the frame size of each column per page.
  2. Since this magazine serves both iPhone and iPad carrying zombies, show two columns on iPad and one column on iPhone so the number of columns is appropriate for each screen size.
  3. Inset the entire bounds of the page by the size of the margin to calculate pageRect.
  4. Divide pageRect's width by the number of columns per page and inset that new frame with the margin for columnRect.

Open, CTView.swift, replace the entire contents with the following:

import UIKit
import CoreText

class CTView: UIScrollView {

  //1
  func buildFrames(withAttrString attrString: NSAttributedString,
                   andImages images: [[String: Any]]) {
    //3
    isPagingEnabled = true
    //4
    let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
    //4
    var pageView = UIView()
    var textPos = 0
    var columnIndex: CGFloat = 0
    var pageIndex: CGFloat = 0
    let settings = CTSettings()
    //5
    while textPos < attrString.length {
    }
  }
}
  1. buildFrames(withAttrString:andImages:) will create CTColumnViews then add them to the scrollview.
  2. Enable the scrollview's paging behavior; so, whenever the user stops scrolling, the scrollview snaps into place so exactly one entire page is showing at a time.
  3. CTFramesetter framesetter will create each column's CTFrame of attributed text.
  4. UIView pageViews will serve as a container for each page's column subviews; textPos will keep track of the next character; columnIndex will keep track of the current column; pageIndex will keep track of the current page; and settings gives you access to the app's margin size, columns per page, page frame and column frame settings.
  5. You're going to loop through attrString and lay out the text column by column, until the current text position reaches the end.

Time to start looping attrString. Add the following within while textPos < attrString.length {.:

//1
if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
  columnIndex = 0
  pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
  addSubview(pageView)
  //2
  pageIndex += 1
}   
//3
let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
let columnOffset = columnIndex * columnXOrigin
let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)
  1. If the column index divided by the number of columns per page equals 0, thus indicating the column is the first on its page, create a new page view to hold the columns. To set its frame, take the margined settings.pageRect and offset its x origin by the current page index multiplied by the width of the screen; so within the paging scrollview, each magazine page will be to the right of the previous one.
  2. Increment the pageIndex.
  3. Divide pageView's width by settings.columnsPerPage to get the first column's x origin; multiply that origin by the column index to get the column offset; then create the frame of the current column by taking the standard columnRect and offsetting its x origin by columnOffset.

Next, add the following below columnFrame initialization:

//1   
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: columnFrame.size))
let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
//2
let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
pageView.addSubview(column)
//3
let frameRange = CTFrameGetVisibleStringRange(ctframe)
textPos += frameRange.length
//4
columnIndex += 1
  1. Create a CGMutablePath the size of the column, then starting from textPos, render a new CTFrame with as much text as can fit.
  2. Create a CTColumnView with a CGRect columnFrame and CTFrame ctframe then add the column to pageView.
  3. Use CTFrameGetVisibleStringRange(_:) to calculate the range of text contained within the column, then increment textPos by that range length to reflect the current text position.
  4. Increment the column index by 1 before looping to the next column.

Lastly set the scroll view's content size after the loop:

contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
                     height: bounds.size.height)

By setting the content size to the screen width times the number of pages, the zombies can now scroll through to the end.

Open ViewController.swift, and replace

(view as? CTView)?.importAttrString(parser.attrString)

with the following:

(view as? CTView)?.buildFrames(withAttrString: parser.attrString, andImages: parser.images)

Build and run the app on an iPad. Check that double column layout! Drag right and left to go between pages. Lookin' good. :]

You've columns and formatted text, but you're missing images. Drawing images with Core Text isn't so straightforward - it's a text framework after all - but with the help of the markup parser you've already created, adding images shouldn't be too bad.