Style placeholder text NSSearchField and NSTextField? - swift

I've been creating a MacOS app and am having trouble styling the font of an NSSearchField (named searchField). My code so far is as follows:
Declared at top of single main viewController class:
let normalTextStyle = NSFont(name: "PT Mono", size: 14.0)
let backgroundColour = NSColor(calibratedHue: 0.6,
saturation: 0.5,
brightness: 0.2,
alpha: 1.0)
let normalTextColour = NSColor(calibratedHue: 0.5,
saturation: 0.1,
brightness: 0.9,
alpha: 1.0)
Declared in viewDidLoad:
searchField.backgroundColor = backgroundColour
searchField.textColor = normalTextColour
searchField.font = normalTextStyle
searchField.centersPlaceholder = false
searchField.currentEditor()?.font = normalTextStyle
let attrStr = NSMutableAttributedString(string: "Search...",
attributes: [NSForegroundColorAttributeName: normalTextColour])
searchField.placeholderAttributedString = attrStr
Generally this works except in one condition: when the search field has focus but no search term has been entered. In this case the placeholder text has the correct colour but the font seems to return to the default (Helvetica 12 point?). As soon as something is typed in or the field loses focus, then the correct font is used once more.
I have tried with no luck looking through the Apple docs for some kind of font or colour settings not currently being set. I have fiddled about with all the font setting I could find in the interface builder, including cocoa bindings and the normal settings in the inspector.
Do I need to set some value of the currentEditor? I am guessing not because the font is changed once text is entered.. I am stuck - can anyone help?
EDIT: I've now tried with an NSTextField and the results are the same. Does anyone have any ideas?

I eventually managed to find an answer. I created a new class TitleTextFormatter of type Formatter, which is 'is intended for subclassing. A custom formatter can restrict the input and enhance the display of data in novel ways'. All I needed to do was override certain default functions to get what I needed:
import Cocoa
class TitleTextFormatter: Formatter {
override func string(for obj: Any?) -> String? {
/*
* this function receives the object it is attached to.
* in my case it only ever receives an NSConcreteAttributedString
* and returns a plain string to be formatted by other functions
*/
var result: String? = nil
if let attrStr = obj as? NSAttributedString {
result = attrStr.string
}
return result
}
override func getObjectValue( _ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?,
for string: String,
errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
/*
* this function in general is overridden to provide an object created
* from the input string. in this instance, all I want is an attributed string
*/
let titleParagraphStyle = NSMutableParagraphStyle()
titleParagraphStyle.alignment = .center
let titleAttributes = [NSAttributedStringKey.foregroundColor: NSColor.mainText,
NSAttributedStringKey.font: NSFont.titleText,
NSAttributedStringKey.paragraphStyle: titleParagraphStyle]
let titleAttrStr = NSMutableAttributedString(string: string,
attributes: titleAttributes)
obj?.pointee = titleAttrStr
return true
}
override func attributedString(for obj: Any,
withDefaultAttributes attrs: [NSAttributedStringKey : Any]? = nil) -> NSAttributedString? {
/*
* is overridden to show that an attributed string is created from the
* formatted object. this happens to duplicate what the previous function
* does, only because the object I want to create from string is an
* attributed string
*/
var titleAttrStr: NSMutableAttributedString?
if let str = string(for: obj) {
let titleParagraphStyle = NSMutableParagraphStyle()
titleParagraphStyle.alignment = .center
let titleAttributes = [NSAttributedStringKey.foregroundColor: NSColor.mainText,
NSAttributedStringKey.font: NSFont.titleText,
NSAttributedStringKey.paragraphStyle: titleParagraphStyle]
titleAttrStr = NSMutableAttributedString(string: str,
attributes: titleAttributes)
}
return titleAttrStr
}
}
and then in viewDidLoad I added the following:
let titleTextFormatter = TitleTextFormatter()
titleTextField.formatter = titleTextFormatter

Related

Swift - Attributed Text features using Storyboard all reset when setting font size programmatically and run simulator

On the storyboard I created a text view. Inserted two paragraphs of text content inside textview. Selected custom attributes on the storyboard and made some words bold. When I run the simulator, everything is ok. But when I set the font size programmatically with respect to the "view.frame.height", the bold words which I set on the storyboard resets to regular words.
Code: "abcTextView.font = abcTextView.font?.withSize(self.view.frame.height * 0.021)"
I couldn't get past this issue. How can I solve this?
The problem is that you're working with an AttributedString. Take a look at Manmal's excellent answer here if you want more context, and an explanation of how the code works:
NSAttributedString, change the font overall BUT keep all other attributes?
Here's an easy application of the extension he provides, to put it in the context of your problem:
class ViewController: UIViewController {
#IBOutlet weak var myTextView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let newString = NSMutableAttributedString(attributedString: myTextView.attributedText)
newString.setFontFace(font: UIFont.systemFont(ofSize: self.view.frame.height * 0.033))
myTextView.attributedText = newString
}
}
extension NSMutableAttributedString {
func setFontFace(font: UIFont, color: UIColor? = nil) {
beginEditing()
self.enumerateAttribute(
.font,
in: NSRange(location: 0, length: self.length)
) { (value, range, stop) in
if let f = value as? UIFont,
let newFontDescriptor = f.fontDescriptor
.withFamily(font.familyName)
.withSymbolicTraits(f.fontDescriptor.symbolicTraits) {
let newFont = UIFont(
descriptor: newFontDescriptor,
size: font.pointSize
)
removeAttribute(.font, range: range)
addAttribute(.font, value: newFont, range: range)
if let color = color {
removeAttribute(
.foregroundColor,
range: range
)
addAttribute(
.foregroundColor,
value: color,
range: range
)
}
}
}
endEditing()
}
}

Saving and pasting an attributed string with a custom NSTextBlock

I am trying to create a custom NSTextBlock, much like the one Apple did at WWDC 18 (23 mins in).
Full demo project here.
Okay, so it works great when I'm editing and marking a paragraph with my paragraph style that has the text block attached.
But when I cut and paste it (or archive/unarchive from disk), it loses it. EDIT: It actually turns my TweetTextBlock subclass into a NSTableViewTextBlock, which also explains the borders.
Implementation
Here's a full Xcode project. Use the Format top menu item to trigger the markTweet function.
Here's how I add the attributes to the paragraph
#IBAction func markTweet(_ sender : Any?){
print("now we are marking")
let location = textView.selectedRange().location
guard let nsRange = textView.string.extractRange(by: .byParagraphs, at: location) else { print("Not in a paragraph"); return }
let substring = (textView.string as NSString).substring(with: nsRange)
let tweetParagraph = NSMutableParagraphStyle()
tweetParagraph.textBlocks = [TweetTextBlock()]
let twitterAttributes : [AttKey : Any] = [
AttKey.paragraphStyle : tweetParagraph,
AttKey.font : NSFont(name: "HelveticaNeue", size: 15)
]
textView.textStorage?.addAttributes(twitterAttributes, range: nsRange)
}
And this is my NSTextBlock subclass
import Cocoa
class TweetTextBlock: NSTextBlock {
override init() {
super.init()
setWidth(33.0, type: .absoluteValueType, for: .padding)
setWidth(70.0, type: .absoluteValueType, for: .padding, edge: .minX)
setValue(100, type: .absoluteValueType, for: .minimumHeight)
setValue(300, type: .absoluteValueType, for: .width)
setValue(590, type: .absoluteValueType, for: .maximumWidth)
backgroundColor = NSColor(white: 0.97, alpha: 1.0)
}
override func drawBackground(withFrame frameRect: NSRect, in controlView: NSView,
characterRange charRange: NSRange, layoutManager: NSLayoutManager) {
let frame = frameRect
let fo = frameRect.origin
super.drawBackground(withFrame: frame, in: controlView, characterRange:
charRange, layoutManager: layoutManager)
// draw string
let context = NSGraphicsContext.current
context?.shouldAntialias = true
let drawPoint: NSPoint = CGPoint(x: fo.x + 70, y: fo.y + 10)
let nameAttributes = [AttKey.font: NSFont(name: "HelveticaNeue-Bold", size: 15), .foregroundColor: NSColor.black]
var handleAttributes = [AttKey.font: NSFont(name: "HelveticaNeue", size: 15), .foregroundColor: NSColor(red: 0.3936756253, green: 0.4656872749, blue: 0.5323709249, alpha: 1)]
let nameAStr = NSMutableAttributedString(string: "Johanna Appleseed", attributes: nameAttributes)
let handleAStr = NSAttributedString(string: " #johappleseed · 3h", attributes: handleAttributes)
nameAStr.append(handleAStr)
nameAStr.draw(at: drawPoint)
let im = NSImage(named: "profile-twitter")!
im.draw(in: NSRect(x: fo.x + 10, y: fo.y + 10, width: 50, height: 50))
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
}
What I tried
My thinking is that this might happen because TextKit doesn't know how to archive the attributes from the custom block. But I tried overriding init:fromCoder and encode. They don't get called. Not on copy, paste, archiving, unarchiving. So I suppose that was not it. This leads me to think that all this custom drawing logic can't be saved in an attributed string, and that this is all happening in the layout manager. That makes sense. But how do I persist the block, then?
UPDATE: I tried reading the attributes. It has a paragraph style, and that paragraph style has an item in the textBlocks array property. But that text block is an NSTextBlock and not my subclass (i tried if block is TweetTextBlock which returns false)
UPDATE 2: I tried overriding properties like classForArchiver, and then reading them with e.g. print("twb: Class for archiver", block.classForArchiver). What's interesting here is that the text block has been turned into a NSTextTableBlock! I'm so deep in hacking this now that I'm looking for a way to store the className somewhere in the text block. So far, the only one I can think of is the tooltip property, but that's visible to the user, and I might want to use that for something else.
UPDATE 3: The tooltip is also not preserved. That's weird. The next big hack I can think of is setting the text color to HSB (n, 0, 0), where n is the identifier for the NSTextBlock subclass. Let's hope I don't have to go there.
UPDATE 4. This is most likely caused by both archiving and copy/pasting transforms the string into RTF. Here's public.rtf from my clipboard
{\rtf1\ansi\ansicpg1252\cocoartf2509
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;}
{\colortbl;\red255\green255\blue255;\red245\green245\blue245;}
{\*\expandedcolortbl;;\csgray\c97000;}
\pard\intbl\itap1\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
\f0\fs30 \cf0 THIS text is in a TweetTextBlock}
It appears the NSAttributedString is somehow at fault. I tried subclassing NSMutableParagraphStyle and using it and it is NOT being encoded or decoded (init).
It may be possible to simply annotate the text run with a custom Attribute.Key indicating the delineation of the block content and its "type" and then post-process the AttributedString after the paste.
Alternatively, the out-of-the-box Pasteboard types may not support and archived NSAttributedString. Rather, (and I'm guessing) the highest fidelity text type may be RTF which may account for the fact that the TextBlock NSCoding methods aren't invoked at all.
Looking at NSPasteboard.PasteboardType my vote is option 2.

Fontsize of attributedString doesn't change

I have an attributedString and want to change only it's fontsize. To do that, I use another method that I found on StackOverflow. For most cases, this is working, but somehow it doesn't change the whole attributedString in one case.
Method to change the size:
/**
*A struct with static methods that can be useful for your GUI
*/
struct GuiUtils {
static func setAttributedStringToSize(attributedString: NSAttributedString, size: CGFloat) -> NSMutableAttributedString {
let mus = NSMutableAttributedString(attributedString: attributedString)
mus.enumerateAttribute(.font, in: NSRange(location: 0, length: mus.string.count)) { (value, range, stop) in
if let oldFont = value as? UIFont {
let newFont = oldFont.withSize(size)
mus.addAttribute(.font, value: newFont, range: range)
}
}
return mus
}
}
Working:
label.attributedText = GuiUtils.setAttributedStringToSize(attributedString: attributedString, size: fontSize)
Not working:
mutableAttributedString.replaceCharacters(in: gapRange, with: filledGap)
label.attributedText = GuiUtils.setAttributedStringToSize(attributedString: mutableAttributedString.replaceCharacters, size: fontSize)
Somehow, the replaced text does not change its size.
Excuse me, but do you sure that your filledGap attributed string has font attribute? Because if it doesn't – this part will not be handled by the enumerateAttribute block.
In this case your fix will be just to set any font to the whole filledGap string, to be sure that it's part will be handled by the enumerateAttribute block.

tvOS: Anyway to display a subtitle outside of the AVPlayer?

So the scenario is that there is a view where the user can enable/disable subtitles in an app I'm helping to develop.
On that view there is a sample text saying "This is what captions look like", and at the moment it's just a basic, unstyled UILabel. Ideally I would like it to be styled in a similar manner to how the user has customized their captions in the System Settings.
Is this possible in any way? I've envisioned two possible method:
Create an AVPlayer instance and a .vtt file with the text, load it into the view and pause the player. I'm not sure this is possible with a sample video (and it would somehow have to be transparent as there is an image behind the sample sub text).
Somehow get all the styling (font, size, background color, etc) the user has set for their subtitle and create an attributed string to match that
Method 2 seems like the most feasible way, but I don't know if we have access to those settings in code.
So I figured it out! It basically makes use a combination of the Media Accessibility API, which allows you to get the values the user has chosen for their captions/subtitle settings, Attributed Strings, and a subclass UILabel (although this could maybe be substituted with a UITextView as that will allow you to set it's UIEdgeInsets natively)
So, first, the subclass is to allow the UILabel to be inset. This is because captions can have a background color AND a text highlight color and without the inset, the text highlight is all you see. So the function the subclass is simple:
class InsetUILabel: UILabel {
override func drawTextInRect(rect: CGRect) {
let inset: CGFloat = 15
let insets: UIEdgeInsets = UIEdgeInsets(top: inset, left: inset/2, bottom: inset, right: inset/2)
super.drawTextInRect(UIEdgeInsetsInsetRect(rect, insets))
}
}
And for generating the actual label. This uses a label called textSample, but you can obviously make it a little more general.
import MediaAccessibility
func styleLabel(sampleText: String) {
let domain = MACaptionAppearanceDomain.User
// Background styling
let backgroundColor = UIColor(CGColor: MACaptionAppearanceCopyWindowColor(domain, nil).takeRetainedValue())
let backgroundOpacity = MACaptionAppearanceGetWindowOpacity(domain, nil)
textSample.layer.backgroundColor = backgroundColor.colorWithAlphaComponent(backgroundOpacity).CGColor
textSample.layer.cornerRadius = MACaptionAppearanceGetWindowRoundedCornerRadius(domain, nil)
// Text styling
var textAttributes = [String:AnyObject]()
let fontDescriptor = MACaptionAppearanceCopyFontDescriptorForStyle(domain, nil, MACaptionAppearanceFontStyle.Default).takeRetainedValue()
let fontName = CTFontDescriptorCopyAttribute(fontDescriptor, "NSFontNameAttribute") as! String
let fontColor = UIColor(CGColor: MACaptionAppearanceCopyForegroundColor(domain, nil).takeRetainedValue())
let fontOpacity = MACaptionAppearanceGetForegroundOpacity(domain, nil)
let textEdgeStyle = MACaptionAppearanceGetTextEdgeStyle(domain, nil)
let textHighlightColor = UIColor(CGColor: MACaptionAppearanceCopyBackgroundColor(domain, nil).takeRetainedValue())
let textHighlightOpacity = MACaptionAppearanceGetBackgroundOpacity(domain, nil)
let textEdgeShadow = NSShadow()
textEdgeShadow.shadowColor = UIColor.blackColor()
let shortShadowOffset: CGFloat = 1.5
let shadowOffset: CGFloat = 3.5
switch(textEdgeStyle) {
case .None:
textEdgeShadow.shadowColor = UIColor.clearColor()
case .DropShadow:
textEdgeShadow.shadowOffset = CGSize(width: -shortShadowOffset, height: shortShadowOffset)
textEdgeShadow.shadowBlurRadius = 6
case .Raised:
textEdgeShadow.shadowOffset = CGSize(width: 0, height: shadowOffset)
textEdgeShadow.shadowBlurRadius = 5
case .Depressed:
textEdgeShadow.shadowOffset = CGSize(width: 0, height: -shadowOffset)
textEdgeShadow.shadowBlurRadius = 5
case .Uniform:
textEdgeShadow.shadowColor = UIColor.clearColor()
textAttributes[NSStrokeColorAttributeName] = UIColor.blackColor()
textAttributes[NSStrokeWidthAttributeName] = -2.0
default:
break
}
textAttributes[NSFontAttributeName] = UIFont(name: fontName, size: (textSample.font?.pointSize)!)
textAttributes[NSForegroundColorAttributeName] = fontColor.colorWithAlphaComponent(fontOpacity)
textAttributes[NSShadowAttributeName] = textEdgeShadow
textAttributes[NSBackgroundColorAttributeName] = textHighlightColor.colorWithAlphaComponent(textHighlightOpacity)
textSample.attributedText = NSAttributedString(string: sampleText, attributes: textAttributes)
}
Now the text highlight section makes use of shadows, with values I think look pretty good, but you might want to tweak them a tiny bit. Hope this helps!

Color attribute is ignored in NSAttributedString with NSLinkAttributeName

In an NSAttributedString, a range of letters has a link attribute and a custom color attribute.
In Xcode 7 with Swift 2, it works:
In Xcode 8 with Swift 3, the custom attributed color for the link is always ignored (it should be orange in the screenshot).
Here's the code for testing.
Swift 2, Xcode 7:
import Cocoa
import XCPlayground
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orangeColor(), range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.selectable = true
tf.stringValue = text
tf.attributedStringValue = attr
XCPlaygroundPage.currentPage.liveView = tf
Swift 3, Xcode 8:
import Cocoa
import PlaygroundSupport
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.isSelectable = true
tf.stringValue = text
tf.attributedStringValue = attr
PlaygroundPage.current.liveView = tf
I've sent a bug report to Apple, but in the meantime if someone has an idea for a fix or workaround in Xcode 8, that would be great.
Apple Developer has answered:
Please know that our engineering team has determined that this issue behaves as intended based on the information provided.
And they explain why it worked before but doesn't anymore:
Unfortunately, the previous behavior (attributed string ranges with NSLinkAttributeName rendering in a custom color) was not explicitly supported. It happened to work because NSTextField was only rendering the link when the field editor was present; without the field editor, we fall back to the color specified by NSForegroundColorAttributeName.
Version 10.12 updated NSLayoutManager and NSTextField to render links using the default link appearance, similar to iOS. (see AppKit release notes for 10.12.)
To promote consistency, the intended behavior is for ranges that represent links (specified via NSLinkAttributeName) to be drawn using the default link appearance. So the current behavior is the expected behavior.
(emphasis mine)
This answer is not a fix for the issue of NSLinkAttributeName ignoring custom colors, it's an alternative solution for having colored clickable words in NSAttributedString.
With this workaround we don't use NSLinkAttributeName at all, since it forces a style we don't want.
Instead, we use custom attributes, and we subclass the NSTextField/NSTextView to detect the attributes under the mouse click and act accordingly.
There's several constraints, obviously: you have to be able to subclass the field/view, to override mouseDown, etc, but "it works for me" while waiting for a fix.
When preparing your NSMutableAttributedString, where you would have set an NSLinkAttributeName, set the link as an attribute with a custom key instead:
theAttributedString.addAttribute("CUSTOM", value: theLink, range: theLinkRange)
theAttributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: theLinkRange)
theAttributedString.addAttribute(NSCursorAttributeName, value: NSCursor.arrow(), range: theLinkRange)
The color and content for the link is set. Now we have to make it clickable.
For this, subclass your NSTextView and override mouseDown(with event: NSEvent).
We will get the location of the mouse event in the window, find the character index in the text view at that location, and ask for the attributes of the character at this index in the text view's attributed string.
class MyTextView: NSTextView {
override func mouseDown(with event: NSEvent) {
// the location of the click event in the window
let point = self.convert(event.locationInWindow, from: nil)
// the index of the character in the view at this location
let charIndex = self.characterIndexForInsertion(at: point)
// if we are not outside the string...
if charIndex < super.attributedString().length {
// ask for the attributes of the character at this location
let attributes = super.attributedString().attributes(at: charIndex, effectiveRange: nil)
// if the attributes contain our key, we have our link
if let link = attributes["CUSTOM"] as? String {
// open the link, or send it via delegate/notification
}
}
// cascade the event to super (optional)
super.mouseDown(with: event)
}
}
That's it.
In my case I needed to customize different words with different colors and link types, so instead of passing just the link as a string I pass a struct containing the link and additional meta information, but the idea is the same.
If you have to use an NSTextField instead of an NSTextView, it's a bit trickier to find the click event location. A solution is to create an NSTextView inside the NSTextField and from there use the same technique as before.
class MyTextField: NSTextField {
var referenceView: NSTextView {
let theRect = self.cell!.titleRect(forBounds: self.bounds)
let tv = NSTextView(frame: theRect)
tv.textStorage!.setAttributedString(self.attributedStringValue)
return tv
}
override func mouseDown(with event: NSEvent) {
let point = self.convert(event.locationInWindow, from: nil)
let charIndex = referenceView.textContainer!.textView!.characterIndexForInsertion(at: point)
if charIndex < self.attributedStringValue.length {
let attributes = self.attributedStringValue.attributes(at: charIndex, effectiveRange: nil)
if let link = attributes["CUSTOM"] as? String {
// ...
}
}
super.mouseDown(with: event)
}
}