When I use Xcode 12 to create a "Document App", the app template that is generated is one where the CoreData-backed "Document" represents the current tab, as seen below:
So basically if I hit cmd-S, the semantics are the to save the content of that one currently-active tab.
However, what if I wanted the "Document" to represent all the tabs in that window? Given that these default window tabs are sort of baked in, is Cocoa flexible enough to fit my design criteria?
The sample you've posted contains multiple documents (Untitled, Untitled 2, ..) inside a single window. Each of these tabs is a separate document with the tabbed interface handled transparently by macOS.
If you'd like to use tabs inside a single document - like e.g. sheets in a Numbers document - you'd have to implement that functionality on your own.
When a window tab is moved, the tabGroup property of the window changes. The tabGroup property is observable but this is not documented. Window controllers can be moved to another document with addWindowController. I don't know how to prevent a new document from opening in a tab in another document. Accessing the tabGroup seems to work but it's a hack. Switching NSWindow.allowsAutomaticWindowTabbing off and on also seems to work. Here's my test app (Xcode app template, document based, XIB. Add a WindowController subclass and change the class of the file's owner in Document.xib.)
class Document: NSDocument {
var documentData: [String]? // String per window
func addWindowTab(_ data: String?) {
let windowController = WindowController(windowNibName: NSNib.Name("Document"))
if let text = data {
windowController.text = text
}
if let otherWindowController = windowControllers.first, // document has other window(s)
let tabgroup = otherWindowController.window?.tabGroup,
let newWindow = windowController.window {
tabgroup.addWindow(newWindow)
}
else {
_ = windowController.window?.tabGroup // access tabGroup to open new documents in a new window
windowController.showWindow(self)
}
addWindowController(windowController)
}
override func makeWindowControllers() {
if let data = documentData {
for windowData in data {
addWindowTab(windowData)
}
}
else {
addWindowTab(nil)
}
}
func windowControllerDidChangeTabGroup(_ windowController: NSWindowController) {
var destinationDocument: Document?
// check if the window is in a tabgroup with windows of another document
if let tabGroup = windowController.window?.tabGroup,
tabGroup.windows.count > 1 {
for otherWindow in tabGroup.windows {
if let otherWindowController = otherWindow.delegate as? WindowController,
otherWindowController.document !== self {
destinationDocument = otherWindowController.document as? Document
break;
}
}
}
// check if this document has other windows
else if windowControllers.count > 1 {
destinationDocument = Document()
NSDocumentController.shared.addDocument(destinationDocument!)
}
if let destinationDocument = destinationDocument,
destinationDocument !== self {
destinationDocument.addWindowController(windowController) // removes windowController from self
if windowControllers.count == 0 {
close()
}
}
}
}
class WindowController: NSWindowController {
var tabObservation: NSKeyValueObservation?
override func windowDidLoad() {
super.windowDidLoad()
tabObservation = window?.observe(\.tabGroup, options: []) { object, change in
if let document = self.document as? Document {
// accessing tabGroup can change tabGroup and cause recursion, schedule on runloop
DispatchQueue.main.async{
document.windowControllerDidChangeTabGroup(self)
}
}
}
}
override func newWindowForTab(_ sender: Any?) {
if let document = document as? Document {
document.addWindowTab(nil)
}
}
}
What I'm trying to do is store some data from a MacOS App with NSDocument provided class in a file. I decided to use SwiftUI , but all tutorials I found are using Storyboards. And from those I cannot adapt how to get the data from my textfield into my NSDocument class.
As far as I got it I need to init my variables in the NSDocument class like this
class Document: NSDocument {
#objc dynamic var contents = "Foo"
public init(contentString: String) {
self.contents = contentString
}
/* ... */
}
and in the same class I can save this string using
override func data(ofType typeName: String) throws -> Data {
return contents.data(using: .utf8) ?? Data()
throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
}
So in my view generated with SwiftUI I can access this using
struct MainTableView: View {
#State var doc = Document.init()
var body: some View {
TextField("My text", text: self.$doc.contents)
}
}
But - as I'm using only an instance it always saves "Foo" - no matter what I type into my TextField.
Besides - another question that will follow up right away: On the long run I don't want to store a string only. I'll have 3 different 2D-Arrays with different data-structures. Is NSDocument able to handle this by itself or do I need to convert those to JSON/XML/...-String and store this as a file?
Finder and Notes have a peculiar behaviour that I am seeking to reproduce. The ‘flexible space’ in the NSToolbar seems to take the dimensions of the split view into account. For instance, the first group of buttons aligns on the left side with the right side of the sidebar. The second group of icons aligns with the right side of the first column. When I widen the sidebar, the toolbar items move along with it.
Is it possible to reproduce this?
Solution
With the solution provided by #KenThomases, I have implemented this as follows:
final class MainWindowController: NSWindowController {
override func windowDidLoad() {
super.windowDidLoad()
window?.toolbar?.delegate = self
// Make sure that tracking is enabled when the toolbar is completed
DispatchQueue.main.async {
self.trackSplitViewForFirstFlexibleToolbarItem()
}
}
}
extension MainWindowController: NSToolbarDelegate {
func toolbarWillAddItem(_ notification: Notification) {
// Make sure that tracking is evaluated only after the item was added
DispatchQueue.main.async {
self.trackSplitViewForFirstFlexibleToolbarItem()
}
}
func toolbarDidRemoveItem(_ notification: Notification) {
trackSplitViewForFirstFlexibleToolbarItem()
}
/// - Warning: This is a private Apple method and may break in the future.
func toolbarDidReorderItem(_ notification: Notification) {
trackSplitViewForFirstFlexibleToolbarItem()
}
/// - Warning: This method uses private Apple methods that may break in the future.
fileprivate func trackSplitViewForFirstFlexibleToolbarItem() {
guard var toolbarItems = self.window?.toolbar?.items, let splitView = (contentViewController as? NSSplitViewController)?.splitView else {
return
}
// Add tracking to the first flexible space and remove it from the group
if let firstFlexibleToolbarItem = toolbarItems.first, firstFlexibleToolbarItem.itemIdentifier == NSToolbarFlexibleSpaceItemIdentifier {
_ = firstFlexibleToolbarItem.perform(Selector(("setTrackedSplitView:")), with: splitView)
toolbarItems.removeFirst()
}
// Remove tracking from other flexible spaces
for flexibleToolbarItem in toolbarItems.filter({ $0.itemIdentifier == NSToolbarFlexibleSpaceItemIdentifier }) {
_ = flexibleToolbarItem.perform(Selector(("setTrackedSplitView:")), with: nil)
}
}
}
When using macOS 11 or newer, you can insert NSTrackingSeparatorToolbarItem items to the toolbar, which will split up your toolbar in sections, aligned with the dividers of a NSSplitView object.
This example adds the new separator items to a toolbar that already contains the rest of the buttons, configured in Interface Builder or in code. The target splitview concerns a standard configuration of 3 splitviews, including a sidebar panel.
class WindowController: NSWindowController, NSToolbarDelegate {
let mainPanelSeparatorIdentifier = NSToolbarItem.Identifier(rawValue: "MainPanel")
override func windowDidLoad() {
super.windowDidLoad()
self.window?.toolbar?.delegate = self
// Calling the inserts async gives more time to bind with the split viewer, and prevents crashes
DispatchQueue.main.async {
// The .sidebarTrackingSeparator is a built-in tracking separator which always aligns with the sidebar splitview
self.window?.toolbar?.insertItem(withItemIdentifier: .sidebarTrackingSeparator, at: 0)
// Example of a custom mainPanelSeparatorIdentifier
// Index at '3' means that there are 3 toolbar items at the left side
// of this separator, including the first tracking separator
self.window?.toolbar?.insertItem(withItemIdentifier: mainPanelSeparatorIdentifier at: 3)
}
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
if let splitView = (self.contentViewController as? NSSplitViewController)?.splitView {
// You must implement this for custom separator identifiers, to connect the separator with a split view divider
if itemIdentifier == mainPanelSeparatorIdentifier {
return NSTrackingSeparatorToolbarItem(identifier: itemIdentifier, splitView: splitView, dividerIndex: 1)
}
}
return nil
}
}
If you want to add an extra separator, for example for an Inspector panel, simply insert an additional toolbar item identifier to the toolbar, and assign an extra NSTrackingSeparatorToolbarItem to another divider in the itemForItemIdentifier delegate function.
You can do this with Apple-private methods, although that's not allowed in the App Store.
There's a private method, -setTrackedSplitView:, on NSToolbarItem. It takes an NSSplitView* as its parameter. You need to call it on the flexible-space toolbar item that you want to track a split view and pass it the split view it should track. To protect yourself against Apple removing the method, you should check if NSToolbarItem responds to the method before trying to use it.
Since the user can customize and re-order the toolbar, you generally need to enumerate the window's toolbar's items. For the first one whose identifier is NSToolbarFlexibleSpaceItemIdentifier, you set the split view it should track. For all other flexible-space items, you clear (set to nil) the split view to track. You need to do that when the window is first set up and again in the toolbar delegate's -toolbarWillAddItem: and -toolbarDidRemoveItem: methods. There's also another undocumented delegate method, -toolbarDidReorderItem:, where I've found it useful to update the toolbar.
Trying to set the paragraph styles for an NSTextView. Am I doing something wrong here, or putting the code in the wrong place perhaps..?
I have this code in the subclass of my NSTextView.
This does not affect my text view in any way:
class EditorTextView: NSTextView {
override func drawRect(dirtyRect: NSRect) {
super.drawRect(dirtyRect)
// to do
}
override func awakeFromNib() {
var parastyle:NSMutableParagraphStyle = NSMutableParagraphStyle()
parastyle.lineSpacing = 20
self.defaultParagraphStyle = parastyle
}
}
Expected result:
When I start typing into the text view, these styles should have been implemented, unless manually overridden by the user via in app formatting menu items.
Edit: Oh I see. I must apply the styles to the attributed string in text storage, right after the above :
var storagerange = self.attributedString().length
self.textStorage?.addAttribute(NSParagraphStyleAttributeName, value: parastyle, range: NSMakeRange(0, storagerange))
The officially recommended method for XIB/Storyboard localization is to create .xib and .storyboard files inside xx.lproj (where xx is the two letter language ID) for each localization you want to support.
This creates a problem because you have multiple files that in many cases share the same UI, that are prone to change. If you wanted to re-design the UI for one view, you'll have to do it multiple times (worse if you entered the localizable string values in the xib itself). This goes against the DRY principle.
It seems way more efficient to call NSLocalizedString() where you need it, and just use one XIB or Storyboard for one base localization.
So, why should(n't) I create localized XIB/Storyboard files?
You can make a category on UILabel, UIButton etc. like this:
#import "UILabel+Localization.h"
#implementation UILabel (Localization)
- (void)setLocalizeKey:(NSString*)key
{
self.text = NSLocalizedString(key, nil);
}
#end
and after that on your xib file use User Defined Runtime Attributes to link the UILabel (or UIButton etc.) to a key saved in your Localizable.strings file
This way you can have all your strings in one file and you do not have to create a separate xib for each language.
For just changing text labels I did something like this
+(void) replaceTextWithLocalizedTextInSubviewsForView:(UIView*)view
{
for (UIView* v in view.subviews)
{
if (v.subviews.count > 0)
{
[self replaceTextWithLocalizedTextInSubviewsForView:v];
}
if ([v isKindOfClass:[UILabel class]])
{
UILabel* l = (UILabel*)v;
l.text = NSLocalizedString(l.text, nil);
[l sizeToFit];
}
if ([v isKindOfClass:[UIButton class]])
{
UIButton* b = (UIButton*)v;
[b setTitle:NSLocalizedString(b.titleLabel.text, nil) forState:UIControlStateNormal];
}
}
}
call this function in your viewDidLoad: like this:
[[self class] replaceTextWithLocalizedTextInSubviewsForView:self.view];
It saved me a lot of work declaring and connecting IBOutlets when all you want is localized labels.
Flax's solution works well, one thing to note is that if you have UILabels or UIButtons which are are contained in UICollectionViewCells in UICollectionViews (or similar) and these collections change frequently in the current view, eg due to user action or being populated by an asynchronous request, then to keep the labels updated with the correct localization strings you can call the localization function in viewDidLayoutSubviews instead of viewDidLoad (which is called only once):
- (void)viewDidLayoutSubviews
{
[LocalizationHelper replaceTextWithLocalizedTextInSubviewsForView:self.view];
}
As can be seen from this code, I keep the localization method in a static helper class (as the other chap suggested):
#implementation LocalizationHelper
+(void) replaceTextWithLocalizedTextInSubviewsForView:(UIView*)view
{
for (UIView* v in view.subviews)
... <code from above> ...
}
#end
Would have added this as a comment to the above solution, but I ain't got no 'rep!
As explained by Leszek S you can create a category.
Here I'll give you an example in swift 3 with extension for UILabel and UIButton:
First of all create a "StringExtension.swift" file
Add on it this code:
extension String {
func localized() -> String {
let bundle = Bundle.main
return NSLocalizedString(self, tableName: nil, bundle: bundle, value: "", comment: "")
}
}
Then create another new file with the name you want (for example) "LocalizableObjectsExtensions.swift"
Add on it an extension for UIButton and one for UILabel like this (of course you can create extension for what you want, UITextField...):
extension UIButton {
var localizedText: String {
set (key) { setTitle(key.localized(), for: .normal) }
get { return titleLabel!.text! }
}
}
extension UILabel {
var localizedText: String {
set (key) { text = key.localized() }
get { return text! }
}
}
Now go in your Storyboard and for your button and/or you label that you want localize just add in the identity inspector of you object this:
FYI: here Key Path it's the name of the function you added in your extensions (UIlabel and UIButton) and Value is the name of the key that you want translate automatically which is in your Localizable.strings file. For example in your Localizable.strings (French) you have the key/value "ourOffers" = "NOS OFFRES";
Now build & Run. Your Object will be translated in the language of your device if you have the key/value in your Localizable.string. Enjoy :)
you can automate a lot of it with ibtool. this is a decent introduction: http://www.bdunagan.com/2009/03/15/ibtool-localization-made-easy/
Every place I look says that you have to replicate the entire xib file for each localization instance, even though you really only wanted to rip the text out and replicate the text in a different language for each localization instance.
If anyone knows of a method to replicate only the user visible text of an xib (in a different language) without replicating the entire xib file for each language, please let us know.
Useful post, much easier than multiple XIBs. I extended the code to handle UISegmentedControl:
if ([v isKindOfClass:[UISegmentedControl class]]) {
UISegmentedControl* s = (UISegmentedControl*)v;
for (int i = 0; i < s.numberOfSegments; i++) {
[s setTitle:NSLocalizedString([s titleForSegmentAtIndex:i],nil) forSegmentAtIndex:i];
}
}
I was looking for the exactly answer given by Flax, marked as right, but I needed it in Swift. So I translated into it. Thanks Flax.
func replaceTextWithLocalizedTextInSubviewsForView(view: UIView) {
for v in view.subviews {
if v.subviews.count > 0 {
self.replaceTextWithLocalizedTextInSubviewsForView(v)
}
if (v.isKindOfClass(UILabel)) {
let myLabel: UILabel = v as! UILabel
myLabel.text = NSLocalizedString(myLabel.text!, comment: "Text to translate.")
myLabel.sizeToFit()
}
if (v.isKindOfClass(UIButton)) {
let myButton: UIButton = v as! UIButton
myButton.setTitle(NSLocalizedString((myButton.titleLabel?.text)!, comment: "Text to translate.") as String, forState: .Normal)
myButton.sizeToFit()
}
}
}
That works for Swift 2.1
I used a similar approach as Leszek Szary described for my views in Swift.
Using a Boolean value as opposed to the localization keys, I added an "On/Off" drop down menu that determines whether the initial text values should be localized or not. This allows for the Storyboard to be kept clean without any extra upkeep.
When a value is selected, a single Runtime Attribute is added to the view and is used as a condition from within it's setter.
Here is the code from my .swift file which extends UIButton, UILabel, UITabBarItem and UITextField, including the text field placeholder and button control states:
import UIKit
extension String {
public var localize: String {
return NSLocalizedString(self, comment: "")
}
}
extension UIButton {
#IBInspectable public var Localize: Bool {
get { return false }
set { if (newValue) {
setTitle( title(for:.normal)?.localize, for:.normal)
setTitle( title(for:.highlighted)?.localize, for:.highlighted)
setTitle( title(for:.selected)?.localize, for:.selected)
setTitle( title(for:.disabled)?.localize, for:.disabled)
}}
}
}
extension UILabel {
#IBInspectable public var Localize: Bool {
get { return false }
set { if (newValue) { text = text?.localize }}
}
}
extension UITabBarItem {
#IBInspectable public var Localize: Bool {
get { return false }
set { if (newValue) { title = title?.localize }}
}
}
extension UITextField {
#IBInspectable public var Localize: Bool {
get { return false }
set { if (newValue) {
placeholder = placeholder?.localize
text = text?.localize
}}
}
}
You could also use the new property to easily translate values that are set while your program is running like this:
let button = UIButton()
button.setTitle("Normal Text", for: .normal)
button.setTitle("Selected Text", for: .selected)
button.Localize = true
I came across this post and several others while trying to make xib localization easier for myself. I posted my method of including IBOutles for labels/buttons on this question, worked great for me, keeps all changes limited to the Localization.strings files.
https://stackoverflow.com/a/15485572/1449834
IMHO Xcode has one among the worst localization features available around...
I really don't like developing for Android but I must admit Android Studio has a better localization system.
That said, because I really cannot stand anymore to recreate Storyboard.strings after each mod (you know, Xcode won't update them for you...), this is how I do :
I have a couple of extensions to loop subviews (and subviews of subviews) and I deal with each of the main objects (labels, textfield, buttons...) by localizing their main properties (text, placeholde...) through a simple helper (AltoUtil.ls) which is a "short" version for NSLocalizedString.
Then I insert texts and placeholders with underscores (for example "_first_name", "_email_address") in my storyboard/xibs and I add those strings to each Localizable.strings file.
Now I just need to call the localize() function in viewDidLoad (or whereber I need it) so that I can have the whole view controller localized. For cells I just call the localize() inside the awakeFromNib() method for example.
I'm sure this is not the fastest method (due to subviews loop) but I don't get any slowdown compared to other methods I used to use and it's pretty productive.
import UIKit
extension UIView {
func localize()
{
for view in self.allSubviews()
{
if let label = view as? UILabel
{
label.text = AltoUtil.ls(label.text)
}
else if let textField = view as? UITextField
{
textField.text = AltoUtil.ls(textField.text)
textField.placeholder = AltoUtil.ls(textField.placeholder)
}
else if let button = view as? UIButton
{
button.setTitle(AltoUtil.ls(button.title(for: UIControl.State.normal)), for: UIControl.State.normal)
}
else if let searchBar = view as? UISearchBar
{
searchBar.placeholder = AltoUtil.ls(searchBar.placeholder)
}
}
}
func allSubviews() -> [UIView]
{
return subviews + subviews.flatMap { $0.allSubviews() }
}
}
The second extension is needed to localize view controllers title and tab bar items in view controllers. You can add any item you need to localize.
import UIKit
extension UIViewController {
func localize()
{
self.title = AltoUtil.ls(self.navigationItem.title)
self.tabBarItem?.title = AltoUtil.ls(self.tabBarItem?.title)
self.view.localize()
}
}