CircleView/CircleView.swift
/* |
Copyright (C) 2017 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
A NSView subclass showing how to draw text using the Cocoa text system, and how render the view in Inteface Builder. |
*/ |
import Cocoa |
// CircleView draws text around a circle, using Cocoa's text system for |
// glyph generation and layout, then calculating the positions of glyphs |
// based on that layout, and using NSLayoutManager for drawing. |
@IBDesignable |
public class CircleView: NSView { |
var timer: Timer? |
var lastAnimationTime: TimeInterval = 0 |
var center: NSPoint { |
didSet { |
self.needsDisplay = true |
} |
} |
var radius: CGFloat = 115.0 { |
didSet { |
self.needsDisplay = true |
} |
} |
var startingAngle: CGFloat = .pi / 2 { |
didSet { |
self.needsDisplay = true |
} |
} |
var angularVelocity: CGFloat = .pi / 2 { |
didSet { |
self.needsDisplay = true |
} |
} |
var string: String { |
get { |
return self.textStorage.string |
} |
set { |
self.textStorage.replaceCharacters(in: NSRange(location: 0, length:self.textStorage.length), with: newValue) |
self.needsDisplay = true |
} |
} |
let textStorage: NSTextStorage = NSTextStorage(string: "Here's to the crazy ones, the misfits, the rebels, the troublemakers, the round pegs in the square holes, the ones who see things differently.") |
let layoutManager: NSLayoutManager = NSLayoutManager() |
let textContainer: NSTextContainer = NSTextContainer() |
var textColor: NSColor = NSColor.black { |
didSet { |
// Text drawing uses the attributes set on the text storage rather |
// than drawing context attributes like the current color. |
let range = NSRange(location: 0, length: self.textStorage.length) |
self.textStorage.addAttribute(NSAttributedStringKey.foregroundColor, value: textColor, range: range) |
self.needsDisplay = true |
} |
} |
required public init?(coder: NSCoder) { |
// This initalizer is used when running the app, as well as when rendering the view as an IBDesignable |
// First, we set default values for the various parameters. |
self.center = NSPoint(x: 180, y: 135) |
super.init(coder: coder) |
// Setup the text system using NSTextStorage, NSLayoutManager, and NSTextContainer |
self.layoutManager.addTextContainer(self.textContainer) |
self.textStorage.addLayoutManager(self.layoutManager) |
} |
deinit { |
timer?.invalidate() |
} |
override public func draw(_ dirtyRect: NSRect) { |
super.draw(dirtyRect) |
NSColor.white.set() |
self.bounds.fill() |
// Note that usedRect(for:) does not force layout, so it must |
// be called after glyphRange(for:), which does force layout. |
let glyphRange = self.layoutManager.glyphRange(for: self.textContainer) |
let usedRect = self.layoutManager.usedRect(for: self.textContainer) |
for glyphIndex in glyphRange.location ..< NSMaxRange(glyphRange) { |
let context = NSGraphicsContext.current |
let lineFragmentRect = self.layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) |
var layoutLocation = self.layoutManager.location(forGlyphAt: glyphIndex) |
let transform = NSAffineTransform() |
// Here layoutLocation is the location (in container coordinates) where the glyph was laid out. |
layoutLocation.x += lineFragmentRect.origin.x |
layoutLocation.y += lineFragmentRect.origin.y |
// We then use the layoutLocation to calculate an appropriate position for the glyph |
// around the circle (by angle and distance, or viewLocation in rectangular coordinates). |
let distance = self.radius + usedRect.size.height - layoutLocation.y |
let angle = self.startingAngle + layoutLocation.x / distance |
let viewLocationX = center.x + distance * sin(angle) |
let viewLocationY = center.y + distance * cos(angle) |
let viewLocation = NSPoint(x: viewLocationX, y: viewLocationY) |
// We use a different affine transform for each glyph, to position and rotate it |
// based on its calculated position around the circle. |
transform.translateX(by: viewLocation.x, yBy: viewLocation.y) |
transform.rotate(byRadians: -angle) |
// We save and restore the graphics state so that the transform applies only to this glyph. |
context?.saveGraphicsState() |
transform.concat() |
// drawGlyphs(forGlyphRange:at:) draws the glyph at its laid-out location in container coordinates. |
// Since we are using the transform to place the glyph, we subtract the laid-out location here. |
layoutManager.drawGlyphs(forGlyphRange: NSRange(location: glyphIndex, length:1), at: NSPoint(x: -layoutLocation.x, y: -layoutLocation.y)) |
context?.restoreGraphicsState() |
} |
} |
override public var isOpaque: Bool { |
return false |
} |
} |
extension CircleView { |
private func startAnimation() { |
self.stopAnimation() |
// We schedule a timer for a desired 30fps animation rate. |
// In CircleView.animate(with:) we determine exactly how much time has elapsed and animate accordingly. |
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true, block: { (timer) in |
self.animate(with: timer) |
}) |
// The next two lines make sure that animation will continue to occur |
// while modal panels are displayed and while event tracking is taking |
// place (for example, while a slider is being dragged). |
RunLoop.current.add(timer!, forMode: .modalPanelRunLoopMode) |
RunLoop.current.add(timer!, forMode: .eventTrackingRunLoopMode) |
lastAnimationTime = Date.timeIntervalSinceReferenceDate |
} |
private func stopAnimation() { |
timer?.invalidate() |
timer = nil |
} |
func animate(with timer: Timer) { |
// We determine how much time has elapsed since the last animation, |
// and we advance the angle accordingly. |
let now = Date.timeIntervalSinceReferenceDate |
startingAngle = self.startingAngle + self.angularVelocity * CGFloat(now - lastAnimationTime) |
lastAnimationTime = now |
} |
func toggleAnimation() { |
if timer == nil { |
startAnimation() |
} else { |
stopAnimation() |
} |
} |
} |
Copyright © 2017 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2017-08-17