AccessibilityUIExamples/LayoutArea/CustomLayoutAreaView.swift
/* |
See LICENSE folder for this sample’s licensing information. |
Abstract: |
An example demonstrating adding accessibility to a view that serves to layout UIs by implementing the NSAccessibilityLayoutArea protocol. |
*/ |
import Cocoa |
/* |
IMPORTANT: This is not a template for developing a custom control. |
This sample is intended to demonstrate how to add accessibility to |
existing custom controls that are not implemented using the preferred methods. |
For information on how to create custom controls please visit http://developer.apple.com |
*/ |
// Used to iterate over the HandlePosition values. |
protocol HandleCollection: Hashable {} |
extension HandleCollection { |
static func cases() -> AnySequence<Self> { |
typealias Myself = Self |
return AnySequence { () -> AnyIterator<Myself> in |
var rawValue = 0 |
return AnyIterator { |
let current: Self = withUnsafePointer(to: &rawValue) { $0.withMemoryRebound(to: Myself.self, capacity: 1) { $0.pointee } } |
guard current.hashValue == rawValue else { return nil } |
rawValue += 1 |
return current |
} |
} |
} |
} |
class CustomLayoutAreaView: NSView, NSAccessibilityLayoutArea { |
// MARK: - Internals |
struct LayoutInfo { |
static let LayoutItemHandleSize = CGFloat(8.0) |
static let LayoutItemMinSize = CGFloat(LayoutItemHandleSize * 3.0) |
static let LayoutItemMoveDelta = CGFloat(10.0) |
static let LayoutItemSize = CGFloat(50.0) |
} |
enum HandlePosition: HandleCollection { |
case unknown |
case north |
case northEast |
case east |
case southEast |
case south |
case southWest |
case west |
case northWest |
} |
var layoutItems = [LayoutItem]() |
var layoutItemsNeedOrdering = false |
// MARK: - View Lifecycle |
required override init(frame frameRect: NSRect) { |
super.init(frame: frameRect) |
commonInit() |
} |
required init?(coder aDecoder: NSCoder) { |
super.init(coder: aDecoder) |
commonInit() |
} |
func commonInit() { |
let rectangleA = LayoutItem() |
rectangleA.setAccessibilityParent(self) |
rectangleA.bounds = NSRect(x: 0, y: 0, width: LayoutInfo.LayoutItemSize, height: LayoutInfo.LayoutItemSize) |
rectangleA.setAccessibilityLabel(NSLocalizedString("Rectangle A", |
comment: "accessibility layout for the first layout item")) |
layoutItems.append(rectangleA) |
let rectangleB = LayoutItem() |
rectangleB.setAccessibilityParent(self) |
rectangleB.bounds = NSRect(x: 75, y: 75, width: LayoutInfo.LayoutItemSize, height: LayoutInfo.LayoutItemSize) |
rectangleB.setAccessibilityLabel(NSLocalizedString("Rectangle B", |
comment: "accessibility label for the second layout item")) |
layoutItems.append(rectangleB) |
} |
// MARK: - Layout Item Management |
var selectedLayoutItemIndex: Int = -1 { |
didSet { |
if (selectedLayoutItemIndex >= layoutItems.count) || (selectedLayoutItemIndex < 0) { |
selectedLayoutItemIndex = -1 |
} else { |
bringItemToTop(itemIndex: selectedLayoutItemIndex) |
} |
NSAccessibilityPostNotification(self, NSAccessibilityNotificationName.focusedUIElementChanged) |
needsDisplay = true |
} |
} |
var selectedLayoutItem: LayoutItem? { |
didSet { |
guard selectedLayoutItem != nil else { return } |
if let itemIndex = layoutItems.index(of: selectedLayoutItem!) { |
let newItemIndex = (itemIndex != NSNotFound) ? itemIndex : -1 |
selectedLayoutItemIndex = newItemIndex |
} |
} |
} |
fileprivate func layoutItemsInZOrder() -> [LayoutItem] { |
_ = layoutItems.sorted(by: { $0.zOrder > $1.zOrder }) |
return layoutItems |
} |
fileprivate func bringItemToTop(itemIndex: Int) { |
if itemIndex >= 0 && itemIndex < layoutItems.count { |
var zOrder = 0 |
for layoutItem in layoutItemsInZOrder() { |
layoutItem.zOrder = zOrder |
zOrder += 1 |
} |
} |
} |
// MARK: - Keyboard Events |
fileprivate func handleArrowKeys(with event: NSEvent) { |
// We allow up/down arrow keys to change the current selection, left/right arrow keys to expand/collapse. |
guard event.modifierFlags.contains(.numericPad), |
let charactersIgnoringModifiers = event.charactersIgnoringModifiers, charactersIgnoringModifiers.characters.count == 1, |
let char = charactersIgnoringModifiers.characters.first |
else { |
super.keyDown(with: event) |
return |
} |
let layoutItem = layoutItems[selectedLayoutItemIndex] |
var bounds = layoutItem.bounds |
switch char { |
case Character(NSDownArrowFunctionKey)!: |
bounds.origin.y -= LayoutInfo.LayoutItemMoveDelta |
case Character(NSUpArrowFunctionKey)!: |
bounds.origin.y += LayoutInfo.LayoutItemMoveDelta |
case Character(NSLeftArrowFunctionKey)!: |
bounds.origin.x -= LayoutInfo.LayoutItemMoveDelta |
case Character(NSRightArrowFunctionKey)!: |
bounds.origin.x += LayoutInfo.LayoutItemMoveDelta |
default: break |
} |
if !(bounds == layoutItem.bounds) { |
layoutItem.bounds = bounds |
} |
needsDisplay = true |
} |
override func keyDown(with event: NSEvent) { |
guard let charactersIgnoringModifiers = event.charactersIgnoringModifiers, charactersIgnoringModifiers.characters.count == 1, |
let char = charactersIgnoringModifiers.characters.first |
else { |
super.keyDown(with: event) |
return |
} |
switch char { |
case Character(NSTabCharacter)!: |
if selectedLayoutItemIndex < (NSInteger)(layoutItems.count - 1) { |
selectedLayoutItemIndex += 1 |
} |
case Character(NSBackTabCharacter)!: // Shift-tab |
if selectedLayoutItemIndex > 0 { |
selectedLayoutItemIndex -= 1 |
} |
default: |
handleArrowKeys(with: event) |
break |
} |
needsDisplay = true |
} |
// MARK: - Mouse Events |
fileprivate func hitTestForLayoutItemAtPoint(point: NSPoint) -> LayoutItem? { |
// Look for any layout item under the mouse click. |
for layoutItem in layoutItemsInZOrder() { |
if layoutItem.bounds.contains(point) { |
// Found a layout item the mouse clicked on. |
return layoutItem |
} |
} |
return nil |
} |
fileprivate func hitTestForHandleAtPoint(point: NSPoint) -> HandlePosition { |
var hitTestHandle: HandlePosition = .unknown |
if selectedLayoutItemIndex >= 0 { |
let cases = Array(HandlePosition.cases()) |
for position in cases { |
let handleRect = handleRectForItemIndex(itemIndex: selectedLayoutItemIndex, |
position: position) |
if handleRect.contains(point) { |
hitTestHandle = position |
break |
} |
} |
} |
return hitTestHandle |
} |
override func mouseDown(with event: NSEvent) { |
let mouseDownPoint = convert(event.locationInWindow, from: nil) |
var currentSelectedlayoutItem = selectedLayoutItem |
let selectedHandlePosition = hitTestForHandleAtPoint(point: mouseDownPoint) |
if selectedHandlePosition == .unknown { |
currentSelectedlayoutItem = hitTestForLayoutItemAtPoint(point: mouseDownPoint) |
selectedLayoutItem = currentSelectedlayoutItem |
} |
var deltaX = CGFloat(0) |
var deltaY = CGFloat(0) |
let bounds = selectedLayoutItem?.bounds |
var currentEvent = event |
let eventMask: NSEvent.EventTypeMask = [NSEvent.EventTypeMask.leftMouseUp, NSEvent.EventTypeMask.leftMouseDragged] |
let untilDate = NSDate.distantFuture |
var stop = false |
repeat { |
var mousePoint = convert(currentEvent.locationInWindow, from: nil) |
switch currentEvent.type { |
case NSEvent.EventType.leftMouseDown, NSEvent.EventType.leftMouseDragged: |
// User dragged a layout item. |
mousePoint = convert(currentEvent.locationInWindow, from: nil) |
deltaX = mousePoint.x - mouseDownPoint.x |
deltaY = mousePoint.y - mouseDownPoint.y |
selectedLayoutItem?.bounds = rectForLayoutItem(rect: bounds!, handle: selectedHandlePosition, deltaX: deltaX, deltaY: deltaY) |
// As the user keeps dragging the mouse get the next event. |
currentEvent = |
(window?.nextEvent(matching: eventMask, until: untilDate, inMode: RunLoopMode.eventTrackingRunLoopMode, dequeue: true))! |
default: |
// User stopped tracking the layout item. |
stop = true |
break |
} |
display() |
} |
while !stop |
} |
// MARK: - Drawing |
override func draw(_ dirtyRect: NSRect) { |
if window?.firstResponder != self { |
selectedLayoutItemIndex = -1 |
} |
// Draw the layout area's background and border. |
let outline = NSBezierPath(rect: bounds) |
NSColor.white.set() |
outline.lineWidth = 2.0 |
outline.fill() |
NSColor.lightGray.set() |
outline.stroke() |
let interimArray = NSArray(array: layoutItemsInZOrder()) |
let enumerator = interimArray.reverseObjectEnumerator() |
let items = enumerator.allObjects |
for case let layoutItem as LayoutItem in items { |
let itemPath = NSBezierPath(rect: layoutItem.bounds) |
NSColor.blue.set() |
itemPath.fill() |
NSColor.black.set() |
itemPath.stroke() |
} |
// Draw all the layout item handles. |
let iterIdx = selectedLayoutItemIndex |
if iterIdx >= 0 { |
let handles = Array(HandlePosition.cases()) |
for position in handles { |
let handleRect = |
handleRectForItemIndex(itemIndex: selectedLayoutItemIndex, position: position) |
let handlePath = NSBezierPath(rect: handleRect) |
NSColor.gray.set() |
handlePath.fill() |
NSColor.black.set() |
} |
} |
} |
} |
// MARK: - |
extension CustomLayoutAreaView { |
// MARK: First Responder |
// Set to allow keyDown, moveLeft, moveRight to be called. |
override var acceptsFirstResponder: Bool { return true } |
override func becomeFirstResponder() -> Bool { |
let didBecomeFirstResponder = super.becomeFirstResponder() |
if didBecomeFirstResponder { |
setKeyboardFocusRingNeedsDisplay(bounds) |
// If user is tabbing through the key loop and that's how this element became first responder, |
// select the first or last layout item depending on the direction of motion through the key loop. |
let event = NSApp.currentEvent |
if event?.type == NSEvent.EventType.keyDown { |
guard let charactersIgnoringModifiers = event?.charactersIgnoringModifiers, charactersIgnoringModifiers.characters.count == 1, |
let char = charactersIgnoringModifiers.characters.first else { return true } |
switch char { |
case Character(NSBackTabCharacter)!: // Shift-tab |
if selectedLayoutItemIndex > 0 { |
selectedLayoutItemIndex -= 1 |
} |
default: break |
} |
} |
} |
return didBecomeFirstResponder |
} |
override func resignFirstResponder() -> Bool { |
let didResignFirstResponder = super.resignFirstResponder() |
if didResignFirstResponder { |
setKeyboardFocusRingNeedsDisplay(bounds) |
selectedLayoutItemIndex = -1 |
needsDisplay = true |
} |
return didResignFirstResponder |
} |
} |
// MARK: - |
extension CustomLayoutAreaView { |
// MARK: NSAccessibilityLayoutArea |
override var accessibilityFocusedUIElement: Any { |
if let accessibilityFocusedUIElement = selectedLayoutItem { |
return accessibilityFocusedUIElement |
} else { |
return super.accessibilityFocusedUIElement! |
} |
} |
override func accessibilityLabel() -> String { |
return NSLocalizedString("Rectangles", comment: "accessibility label for the layout area") |
} |
override func accessibilityChildren() -> [Any]? { |
return layoutItems |
} |
override func accessibilitySelectedChildren() -> [Any]? { |
if let selectedItem = selectedLayoutItem { |
let accessibilitySelectedChildren: [LayoutItem] = [selectedItem] |
return accessibilitySelectedChildren |
} |
return super.accessibilitySelectedChildren() |
} |
} |
Copyright © 2017 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2017-09-12