Photo Editor/SidebarViewController.swift
/* |
Copyright (C) 2016 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
The SidebarViewController controls the sidebar. The user can use the plus button to select a directory to show the sidebar contents. This user chosen directory is then saved as a bookmark for proper operation with Sandboxing when re-opening the window. |
*/ |
import Cocoa |
class SidebarViewController: NSViewController, PhotoControllerConsumer { |
@IBOutlet weak var sidebarTableView: NSTableView! |
@IBOutlet weak var visualEffectView: NSVisualEffectView! |
@IBOutlet weak var sidebarScrollView: NSScrollView! |
// An array of titles to show and icons (images) |
private var tableContents: [ImageItem] = [ImageItem]() |
private var filteredTableContents: [ImageItem] = [ImageItem]() |
private var bookmarkData: Data? |
private var searchContainerViewHeight: CGFloat = 0.0 |
private var appearanceObservationToken: NSObjectProtocol? |
private weak var observingLayoutRectInWindow: NSWindow? |
private var searchString = "" |
// PhotoControllerConsumer protocol implementation |
var photoController: PhotoController? |
deinit { |
if let token = appearanceObservationToken { |
NotificationCenter.default.removeObserver(token) |
} |
} |
override func viewDidLoad() { |
super.viewDidLoad() |
// Set the initial sidebar material |
updateEffectViewAppearance() |
// Watch for changes in the appearance and update our sidebar material |
appearanceObservationToken = NotificationCenter.default.addObserver(forName: .appearanceChanged, object: nil, queue: nil) { [weak self] _ in |
self?.updateEffectViewAppearance() |
} |
// Load the search view controller; this uses hard casts, because we need to know if it fails or some reason (it is a programmer error if it does fail) |
let searchViewController = storyboard!.instantiateController(withIdentifier: "SearchViewController") as! NSViewController |
addChildViewController(searchViewController) |
// Directly add the view to the clip view |
let searchContainerView = searchViewController.view |
let clipView = sidebarScrollView.contentView as! SidebarClipView |
clipView.addSubview(searchContainerView) |
searchContainerViewHeight = searchContainerView.frame.size.height |
clipView.accessoryView = searchContainerView |
// Drop the autoresizing constraints that would be implicitly created for us |
searchContainerView.translatesAutoresizingMaskIntoConstraints = false |
// Create some constraints to pin it to the top; in particular, pin the bottom of the search container to the top of the scrollview (it's 0 position) |
NSLayoutConstraint.activate([ |
clipView.topAnchor.constraint(equalTo: searchContainerView.bottomAnchor), |
clipView.leadingAnchor.constraint(equalTo: searchContainerView.leadingAnchor), |
clipView.trailingAnchor.constraint(equalTo: searchContainerView.trailingAnchor), |
]) |
sidebarScrollView.automaticallyAdjustsContentInsets = false // We manually do it |
} |
private func updateEffectViewAppearance() { |
let appearanceName = UserDefaults.useDarkMode ? NSAppearanceNameVibrantDark : NSAppearanceNameVibrantLight |
visualEffectView.appearance = NSAppearance(named: appearanceName) |
} |
// MARK: - State Restoration |
override func encodeRestorableState(with coder: NSCoder) { |
super.encodeRestorableState(with: coder) |
if let bookmarkData = self.bookmarkData { |
coder.encode(bookmarkData, forKey: "BookmarkData") |
} |
} |
override func restoreState(with coder: NSCoder) { |
super.restoreState(with: coder) |
bookmarkData = coder.decodeObject(forKey: "BookmarkData") as? Data |
if let bookmarkData = self.bookmarkData { |
// If we have data, open it up! |
do { |
var stale = false |
if let directoryURL = try URL(resolvingBookmarkData: bookmarkData, bookmarkDataIsStale: &stale) { |
loadTableContentsFromURL(baseDirectoryURL: directoryURL) |
} |
} catch let error as NSError { |
presentError(error) |
} |
} |
} |
// MARK: - Scroll Inset Management |
private func updateScrollViewContentInsets() { |
let window = view.window! |
let contentLayoutRect = window.contentLayoutRect |
let topInset = (window.contentView!.frame.size.height - contentLayoutRect.height) + searchContainerViewHeight |
sidebarScrollView.contentInsets = EdgeInsets(top: topInset, left: 0, bottom: 0, right: 0) |
} |
override func viewWillAppear() { |
super.viewWillAppear() |
if view.window != observingLayoutRectInWindow { |
// Un-register previous observation, if there was one |
if let observingWindow = observingLayoutRectInWindow { |
observingWindow.removeObserver(self, forKeyPath: "contentLayoutRect") |
} |
// Should always be true in viewWillAppear |
if let window = view.window { |
// Keep an eye on the contentLayoutRect via KVO |
window.addObserver(self, forKeyPath: "contentLayoutRect", options: [], context: nil); |
} |
observingLayoutRectInWindow = view.window |
} |
updateScrollViewContentInsets() |
} |
override func viewWillDisappear() { |
if let observingWindow = observingLayoutRectInWindow { |
observingWindow.removeObserver(self, forKeyPath: "contentLayoutRect") |
observingLayoutRectInWindow = nil |
} |
} |
override func observeValue(forKeyPath keyPath: String?, of object: AnyObject?, change: [NSKeyValueChangeKey : AnyObject]?, context: UnsafeMutablePointer<Void>?) { |
precondition(keyPath! == "contentLayoutRect", "We are only observing the contentLayoutRect") |
updateScrollViewContentInsets() |
} |
private func presentError(error: NSError) { |
if let window = view.window { |
window.presentError(error) |
} else { |
NSApp.presentError(error) |
} |
} |
// MARK: - Data Shuffling |
// saving the data as a URL allows us to do state restoration |
// In order for this to work, com.apple.security.files.bookmarks.app-scope was added to the entitlements plist file |
private func saveURLAsBookmarkData(_ url: URL) { |
do { |
bookmarkData = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) |
invalidateRestorableState() |
} catch let error as NSError { |
presentError(error) |
} |
} |
private func loadTableContentsFromURL(baseDirectoryURL: URL) { |
do { |
let filesURLs = try FileManager.default.contentsOfDirectory(at: baseDirectoryURL, includingPropertiesForKeys: [URLResourceKey.localizedNameKey], options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]) |
let imageUTIs = NSImage.imageTypes() |
for fileURL: URL in filesURLs { |
let resourceValues = try fileURL.resourceValues(forKeys: [.typeIdentifierKey]) |
if let typeIdentifier = resourceValues.typeIdentifier { |
if (imageUTIs.contains(typeIdentifier)) { |
tableContents.append(ImageItem(url: fileURL)) |
} |
} |
} |
filteredTableContents = tableContents |
sidebarTableView.reloadData() |
saveURLAsBookmarkData(baseDirectoryURL) |
} catch let error as NSError { |
presentError(error) |
} |
} |
private func filterTable(with string: String) { |
if searchString != string { |
searchString = string; |
if searchString == "" { |
filteredTableContents = tableContents |
} else { |
filteredTableContents = [] |
for item in tableContents { |
if item.title.localizedCaseInsensitiveContains(string) { |
filteredTableContents.append(item) |
} |
} |
} |
sidebarTableView.reloadData() |
} |
} |
@IBAction func btnOpenClicked(_ sender: NSButton) { |
let openPanel = NSOpenPanel() |
openPanel.allowsMultipleSelection = false |
openPanel.canChooseDirectories = true |
openPanel.canChooseFiles = false |
openPanel.beginSheetModal(for: view.window!) { result in |
if (result == NSModalResponseOK) { |
if let directoryURL = openPanel.url { |
self.loadTableContentsFromURL(baseDirectoryURL: directoryURL) |
} |
} |
} |
} |
@IBAction func searchFieldChanged(_ sender: NSSearchField) { |
filterTable(with: sender.stringValue) |
} |
} |
// Extensions allow a clean and isolated implementation of delegate/datasource methods |
extension SidebarViewController: NSTableViewDelegate, NSTableViewDataSource { |
private func loadImage(for imageItem: ImageItem, preferredSize: NSSize, row: NSInteger) { |
// If we aren't loading it, start now |
guard !imageItem.loadingImage else { return } |
imageItem.loadingImage = true |
let queue = DispatchQueue.global(qos: .userInteractive) |
queue.async { |
let destRect = NSMakeRect(0, 0, preferredSize.width, preferredSize.height) |
// Load the image |
let image = NSImage(contentsOf: imageItem.url)! |
// resize the image; we don't want to do this on the foreground thread, so we create a specific thumbnail to use |
let thumbnailImage = NSImage(size: preferredSize) |
thumbnailImage.lockFocus() |
image.draw(in: destRect) |
thumbnailImage.unlockFocus() |
// Kick back the actual work to the main thread (that is the only location where we access the imageItem for threadsafety) |
DispatchQueue.main.async { |
// Save it for later |
imageItem.image = thumbnailImage; |
imageItem.loadingImage = false |
// Ping this row to reload; the image view may be different at this time, so we get the current cell view (if available) and assign to it |
if let cellView = self.sidebarTableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? NSTableCellView { |
cellView.imageView?.image = thumbnailImage |
} |
} |
} |
} |
func numberOfRows(in tableView: NSTableView) -> Int { |
return filteredTableContents.count |
} |
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { |
let imageItem = filteredTableContents[row] |
// We want this to fail if any conditions aren't met; if it fails, something isn't setup right and should be resolved |
let cellView = tableView.make(withIdentifier: tableColumn!.identifier, owner: self) as! NSTableCellView |
// Display image title/name |
if let label = cellView.textField { |
label.stringValue = imageItem.title |
} |
let imageView = cellView.imageView! |
// Do we have an image? If not, start loading it now... |
if imageItem.image == nil { |
loadImage(for: imageItem, preferredSize: imageView.frame.size, row: row) |
} else { |
imageView.image = imageItem.image |
} |
return cellView |
} |
// Demonstrate how to do "swipe to delete" |
func tableView(_ tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableRowActionEdge) -> [NSTableViewRowAction] { |
switch edge { |
case .trailing: |
let result = NSTableViewRowAction(style: .destructive, title: "Remove", handler: { action, row in |
let item: ImageItem = self.filteredTableContents[row] |
// remove from the filtered and unfiltered item |
self.filteredTableContents.remove(at: row) |
if let i = self.tableContents.index(of: item) { |
self.tableContents.remove(at: i) |
} |
self.sidebarTableView.removeRows(at: IndexSet(integer: row), withAnimation: [.slideUp]) |
}) |
return [result] |
default: |
return [] |
} |
} |
// Demonstrate drag flocking in a table |
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { |
let imageItem = filteredTableContents[row] |
return imageItem.url |
} |
func tableViewSelectionDidChange(_ notification: Notification) { |
// Change the image; this is synchronous. |
let selectedRow = sidebarTableView.selectedRow |
if selectedRow != -1 { |
if let image = NSImage(contentsOf: filteredTableContents[selectedRow].url) { |
photoController?.setPhotoImage(image) |
} |
} |
} |
} |
/// A basic encapsulation of a sidebar item: an image, title, URL and state as to whether or not we have loaded the image (meaning, a thumbnail was created). |
class ImageItem: Equatable { |
var image: NSImage? // Used as a thumbnail image in the SidebarViewController |
var title: String |
var url: URL |
var loadingImage = false |
init(url: URL) { |
self.url = url |
self.title = url.lastPathComponent |
} |
} |
func == (lhs: ImageItem, rhs: ImageItem) -> Bool { |
// same URL means we are the same |
return lhs.url == rhs.url |
} |
Copyright © 2016 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2016-10-27