NSLayoutManager for Line Numbering in UITextVIew - swift

I am attempting to create an iOS LineNumberLayoutManger in Swift to be used in a TextKit 2 UITextView. My problem is the line number variable is being overwritten when I enter a new line with the return key. The revised code sample below can be copied and pasted into an Xcode project in a ViewController to run. Any suggestions very much appreciated.
// ViewController.swift
// tester11
//
// Created by ianshortreed on 2022/07/15.
//
import UIKit
var currentRect:CGRect!
var cgPoint:CGPoint!
extension String {
func substring(with range: NSRange) -> String {
let startIndex = index(self.startIndex, offsetBy: range.location)
let endIndex = index(startIndex, offsetBy: range.length)
//return substring(with: startIndex ..< endIndex)
return String(self[startIndex..<endIndex])
}
}
extension Collection {
var enumerated: Zip2Sequence<PartialRangeFrom<Int32>, Self> { zip(1..., self) }
}
class ViewController: UIViewController, UITextViewDelegate {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
createTextView()
}
func createTextView() {
var textView: UITextView!
var textStorage: NSTextStorage!
// 1
let attrs = [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: UIColor.label]
let attrString = NSAttributedString(string: "Press the return key to add a new line.", attributes: attrs)
textStorage = NSTextStorage()
textStorage.append(attrString)
let newTextViewRect = view.bounds
// 2
//let layoutManager = LineNumberLayoutManager()
let layoutManager = LineNumberLayoutManager()
// 3
let containerSize = CGSize(width: newTextViewRect.width, height: .greatestFiniteMagnitude)
let container = NSTextContainer(size: containerSize)
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
textStorage.addLayoutManager(layoutManager)
// 4
textView = UITextView(frame: newTextViewRect, textContainer: container)
textView.delegate = self
textView.isScrollEnabled = true
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 9.5
//textView.layoutManager.usesFontLeading = false
textView.keyboardDismissMode = .interactive
textView.textContainerInset = UIEdgeInsets(top: 50, left: 10, bottom: 0, right: 10)
textView.typingAttributes = [NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.font: UIFont.systemFont(ofSize: 17)]
textView.textAlignment = NSTextAlignment.justified
textView.allowsEditingTextAttributes = true
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.dataDetectorTypes = .all
view.addSubview(textView)
// 5
textView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
textView.topAnchor.constraint(equalTo: view.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
class LineNumberLayoutManager: NSLayoutManager {
let lineSize = CGSize(width: 8, height: 8)
var lineColor = UIColor(red:1.00, green:1.00, blue:1.00, alpha:1.0)
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
var linenumber = 0
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
guard let textStorage = self.textStorage else { return }
enumerateLineFragments(forGlyphRange: glyphsToShow) { [self] (rect, usedRect, textContainer, glyphRange, _) in
linenumber += 1
let origin = CGPoint(x: 10, y: usedRect.origin.y + 48 + (usedRect.size.height - self.lineSize.height) / 2)
var newLineRange = NSRange(location: 0, length: 0)
if glyphRange.location > 0 {
newLineRange.location = glyphRange.location - 1
newLineRange.length = 1
}
var isNewLine = true
if newLineRange.length > 0 {
isNewLine = textStorage.string.substring(with: newLineRange) == "\n"
}
if isNewLine {
let str = textStorage.string.components(separatedBy: .newlines)
"\(linenumber)\(str)".draw(in:CGRect(origin: origin, size: lineSize))
}
}
}
}
}

Related

How to pass a closure dismissing custom alert avoiding retain cycles

I want to create a custom alert via code. This code below is working, but I Need help on some questions about passing data and retain cycles
is it right my usage of [weak self] ?
I'd like to avoid Delegate Pattern, so my plan is to pass an action as handler, this should keep Controller clean and make code more reusable. Is mine a proper solution?
In my mind, a view should not be "auto removing" but its parent should remove it, so I'd like to pass a reference to the parent controller in order to comunicate via completion, but it seems to create a retain circle (deinit never called), so I'm doing this:
self?.removeFromSuperview() //works, but not sure it is right
//self?.parentController.removeFromParent() //never calls deinit
I had a problem passing closure as parameter inside an init with cgrect as parameter. Is there a proper way other than this solution to handle that?
required init(refersTo: UIViewController, comp: #escaping () -> Void) {
myTransmittedCompletion = comp
self.parentController = refersTo
super.init(frame: CGRect.zero)
}
I call my alert this way
#IBAction func centralButton(_ sender: UIButton) {
let alert = MyAlertInCode(refersTo: self, comp: testPerCOmpletion)
self.view.addSubview(alert)
}
func testPerCOmpletion() {
print("completion")
}
my custom class
class MyAlertInCode: UIView {
let myTransmittedCompletion: () -> Void
let parentController: UIViewController
private let myTitleLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.numberOfLines = 0
v.text = "very long long long logn title"
v.textAlignment = .center
v.font = UIFont.systemFont(ofSize: 28, weight: .bold)
return v
}()
private let mySubtTitleLabel: UILabel = {
let v = UILabel()
v.translatesAutoresizingMaskIntoConstraints = false
v.numberOfLines = 0
v.text = "very long long long logn title"
v.textAlignment = .center
v.font = UIFont.systemFont(ofSize: 20, weight: .bold)
return v
}()
private let myButton: UIButton = {
let v = UIButton()
v.translatesAutoresizingMaskIntoConstraints = false
v.setTitle("Button", for: .normal)
v.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .bold)
v.setTitleColor(.systemBlue, for: .normal)
return v
}()
//white panel of the alert
private let container: UIView = {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
v.backgroundColor = .white
v.layer.cornerRadius = 24
v.backgroundColor = .purple
return v
}()
private lazy var stack: UIStackView = {
let v = UIStackView(arrangedSubviews: [myTitleLabel, mySubtTitleLabel, myButton])
v.translatesAutoresizingMaskIntoConstraints = false
v.axis = .vertical
v.spacing = 10
v.distribution = .fillEqually
v.backgroundColor = .green
return v
}()
required init(refersTo: UIViewController, comp: #escaping () -> Void) {
myTransmittedCompletion = comp
self.parentController = refersTo
super.init(frame: CGRect.zero)
myButton.addTarget(self, action: #selector(methodInsideAlertClass), for: .touchUpInside)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateOut)))
self.backgroundColor = UIColor.gray.withAlphaComponent(0.6)
#warning("UIScreen.main.bounds //deprecated in the future, at 14-ott-2022")
// self.frame = UIScreen.main.bounds //deprecated in the future, at 14-ott-2022
guard let windowBoundsFromIOS13 = UIApplication.shared.currentUIWindow()?.rootViewController?.view.bounds else {return}
self.frame = windowBoundsFromIOS13
self.addSubview(container)
NSLayoutConstraint.activate([
container.centerYAnchor.constraint(equalTo: self.centerYAnchor),
container.centerXAnchor.constraint(equalTo: self.centerXAnchor),
container.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.7),
container.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.5),
])
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.heightAnchor.constraint(equalTo: container.heightAnchor, multiplier: 0.6),
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
])
animateIn()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
print("❌")
}
//MARK: methods
#objc private func methodInsideAlertClass() {
print("methodInsideAlertClass tapped")
}
#objc private func animateOut() {
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 1, options: .curveEaseIn) {
self.container.transform = CGAffineTransform(translationX: 0, y: -self.frame.height)
self.alpha = 0
} completion: { [weak self] isCompleted in
if isCompleted {
self?.myTransmittedCompletion()
self?.removeFromSuperview() // shouldn't be removed by parent view?
// self?.parentController.removeFromParent() //never calls deinit
}
}
}
#objc private func animateIn() {
self.container.transform = CGAffineTransform(translationX: 0, y: -self.frame.height)
self.alpha = 1
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 1, options: .curveEaseIn) {
self.container.transform = .identity
self.alpha = 1
}
}
}
since cannot use Windows:
public extension UIApplication {
func currentUIWindow() -> UIWindow? {
let connectedScenes = UIApplication.shared.connectedScenes
.filter { $0.activationState == .foregroundActive }
.compactMap { $0 as? UIWindowScene }
let window = connectedScenes.first?
.windows
.first { $0.isKeyWindow }
return window
}
}

Adding padding to a UILabel with a background colour

I have a multiline UILabel that contains an NSAttributedString, and this has a background colour applied to give the above effect.
This much is fine but I need padding within the label to give a bit of space on the left. There are other posts on SO addressing this issue, such as by subclassing UILabel to add UIEdgeInsets. However, this merely added padding to the outside of the label for me.
Any suggestions on how padding can be added to this label?
EDIT: Apologies if this has been confusing, but the end goal is something like this...
Based on the answer provided here: https://stackoverflow.com/a/59216224/6257435
Just to demonstrate (several hard-coded values which would, ideally, be dynamic / calculated):
class ViewController: UIViewController, NSLayoutManagerDelegate {
var myTextView: UITextView!
let textStorage = MyTextStorage()
let layoutManager = MyLayoutManager()
override func viewDidLoad() {
super.viewDidLoad()
myTextView = UITextView()
myTextView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(myTextView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
myTextView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
myTextView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
myTextView.widthAnchor.constraint(equalToConstant: 248.0),
myTextView.heightAnchor.constraint(equalToConstant: 300.0),
])
self.layoutManager.delegate = self
self.textStorage.addLayoutManager(self.layoutManager)
self.layoutManager.addTextContainer(myTextView.textContainer)
let quote = "This is just some sample text to demonstrate the word wrapping with padding at the beginning (leading) and ending (trailing) of the lines of text."
self.textStorage.replaceCharacters(in: NSRange(location: 0, length: 0), with: quote)
guard let font = UIFont(name: "TimesNewRomanPSMT", size: 24.0) else {
fatalError("Could not instantiate font!")
}
let attributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.font: font]
self.textStorage.setAttributes(attributes, range: NSRange(location: 0, length: quote.count))
myTextView.isUserInteractionEnabled = false
// so we can see the frame of the textView
myTextView.backgroundColor = .systemTeal
}
func layoutManager(_ layoutManager: NSLayoutManager,
lineSpacingAfterGlyphAt glyphIndex: Int,
withProposedLineFragmentRect rect: CGRect) -> CGFloat { return 14.0 }
func layoutManager(_ layoutManager: NSLayoutManager,
paragraphSpacingAfterGlyphAt glyphIndex: Int,
withProposedLineFragmentRect rect: CGRect) -> CGFloat { return 14.0 }
}
class MyTextStorage: NSTextStorage {
var backingStorage: NSMutableAttributedString
override init() {
backingStorage = NSMutableAttributedString()
super.init()
}
required init?(coder: NSCoder) {
backingStorage = NSMutableAttributedString()
super.init(coder: coder)
}
// Overriden GETTERS
override var string: String {
get { return self.backingStorage.string }
}
override func attributes(at location: Int,
effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] {
return backingStorage.attributes(at: location, effectiveRange: range)
}
// Overriden SETTERS
override func replaceCharacters(in range: NSRange, with str: String) {
backingStorage.replaceCharacters(in: range, with: str)
self.edited(.editedCharacters,
range: range,
changeInLength: str.count - range.length)
}
override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) {
backingStorage.setAttributes(attrs, range: range)
self.edited(.editedAttributes,
range: range,
changeInLength: 0)
}
}
import CoreGraphics //Important to draw the rectangles
class MyLayoutManager: NSLayoutManager {
override init() { super.init() }
required init?(coder: NSCoder) { super.init(coder: coder) }
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
self.enumerateLineFragments(forGlyphRange: glyphsToShow) { (rect, usedRect, textContainer, glyphRange, stop) in
var lineRect = usedRect
lineRect.size.height = 41.0
let currentContext = UIGraphicsGetCurrentContext()
currentContext?.saveGState()
// outline rectangles
//currentContext?.setStrokeColor(UIColor.red.cgColor)
//currentContext?.setLineWidth(1.0)
//currentContext?.stroke(lineRect)
// filled rectangles
currentContext?.setFillColor(UIColor.orange.cgColor)
currentContext?.fill(lineRect)
currentContext?.restoreGState()
}
}
}
Output (teal-background shows the frame):
I used one different way. First I get all lines from the UILabel and add extra blank space at the starting position of every line. To getting all line from the UILabel I just modify code from this link (https://stackoverflow.com/a/55156954/14733292)
Final extension UILabel code:
extension UILabel {
var addWhiteSpace: String {
guard let text = text, let font = font else { return "" }
let ctFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(kCTFontAttributeName as NSAttributedString.Key, value: ctFont, range: NSRange(location: 0, length: attStr.length))
let frameSetter = CTFramesetterCreateWithAttributedString(attStr as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(x: 0, y: 0, width: self.frame.size.width, height: CGFloat.greatestFiniteMagnitude), transform: .identity)
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
guard let lines = CTFrameGetLines(frame) as? [Any] else { return "" }
return lines.map { line in
let lineRef = line as! CTLine
let lineRange: CFRange = CTLineGetStringRange(lineRef)
let range = NSRange(location: lineRange.location, length: lineRange.length)
return " " + (text as NSString).substring(with: range)
}.joined(separator: "\n")
}
}
Use:
let labelText = yourLabel.addWhiteSpace
let attributedString = NSMutableAttributedString(string: labelText)
attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: UIColor.red, range: NSRange(location: 0, length: labelText.count))
yourLabel.attributedText = attributedString
yourLabel.backgroundColor = .yellow
Edited:
The above code is worked at some point but it's not sufficient. So I created one class and added padding and one shape rect layer.
class AttributedPaddingLabel: UILabel {
private let leftShapeLayer = CAShapeLayer()
var leftPadding: CGFloat = 5
var attributedTextColor: UIColor = .red
override func awakeFromNib() {
super.awakeFromNib()
self.addLeftSpaceLayer()
self.addAttributed()
}
override func drawText(in rect: CGRect) {
let insets = UIEdgeInsets(top: 0, left: leftPadding, bottom: 0, right: 0)
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + leftPadding, height: size.height)
}
override var bounds: CGRect {
didSet {
preferredMaxLayoutWidth = bounds.width - leftPadding
}
}
override func draw(_ rect: CGRect) {
super.draw(rect)
leftShapeLayer.path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: leftPadding, height: rect.height)).cgPath
}
private func addLeftSpaceLayer() {
leftShapeLayer.fillColor = attributedTextColor.cgColor
self.layer.addSublayer(leftShapeLayer)
}
private func addAttributed() {
let lblText = self.text ?? ""
let attributedString = NSMutableAttributedString(string: lblText)
attributedString.addAttribute(NSAttributedString.Key.backgroundColor, value: attributedTextColor, range: NSRange(location: 0, length: lblText.count))
self.attributedText = attributedString
}
}
How to use:
class ViewController: UIViewController {
#IBOutlet weak var lblText: AttributedPaddingLabel!
override func viewDidLoad() {
super.viewDidLoad()
lblText.attributedTextColor = .green
lblText.leftPadding = 10
}
}
SwiftUI
This is just a suggestion in SwiftUI. I hope it will be useful.
Step 1: Create the content
let string =
"""
Lorem ipsum
dolor sit amet,
consectetur
adipiscing elit.
"""
Step 2: Apply attributes
let attributed: NSMutableAttributedString = .init(string: string)
attributed.addAttribute(.backgroundColor, value: UIColor.orange, range: NSRange(location: 0, length: attributed.length))
attributed.addAttribute(.font, value: UIFont(name: "Times New Roman", size: 22)!, range: NSRange(location: 0, length: attributed.length))
Step 3: Create AttributedLabel in SwiftUI using a UILabel()
struct AttributedLabel: UIViewRepresentable {
let attributedString: NSAttributedString
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.lineBreakMode = .byClipping
label.numberOfLines = 0
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
uiView.attributedText = attributedString
}
}
Final step: Use it and add .padding()
struct ContentView: View {
var body: some View {
AttributedLabel(attributedString: attributed)
.padding()
}
}
This is the result:

NSMenuItem with custom view disappears while scrolling

I implement a NSMenu with NSMenuItem and set custom view to it. When menu is scrollable, mouse hovering on ▼ button to scroll will cause some menuItem disappear (or not draw correctly). Hope someone give me some help. I will appreciate that.
Here's video about this issue:
https://streamable.com/obrbon
Here's my code:
private func setupMenuItemView(_ menu: NSMenu) {
let menuItemHeight: CGFloat = 20
let menuWidth = frame.width
let textFieldPadding: CGFloat = 10
for menuItem in menu.items {
guard !menuItem.title.isEmpty else { continue }
let menuItemView = MenuItemView(frame: NSRect(x: 0, y: 0, width: Int(menuWidth), height: menuItemHeight))
let textField = MenuItemTextField(labelWithString: menuItem.title)
textField.frame = NSRect(
x: textFieldPadding,
y: (menuItemView.frame.height-textField.frame.height)/2,
width: menuWidth-textFieldPadding*2,
height: textField.frame.height
)
textField.lineBreakMode = .byTruncatingTail
menuItemView.addSubview(textField)
menuItemView.toolTip = menuItem.title
menuItem.view = menuItemView
menuItem.target = self
menuItem.action = #selector(onMenuItemClicked(_:))
}
}
fileprivate class MenuItemView: NSView {
override func mouseUp(with event: NSEvent) {
guard let menuItem = enclosingMenuItem else { return }
guard let action = menuItem.action else { return }
NSApp.sendAction(action, to: menuItem.target, from: menuItem)
menuItem.menu?.cancelTracking()
}
override func draw(_ dirtyRect: NSRect) {
guard let menuItem = enclosingMenuItem else { return }
if menuItem.isHighlighted {
NSColor.alternateSelectedControlColor.set()
} else {
NSColor.clear.set()
}
NSBezierPath.fill(dirtyRect)
super.draw(dirtyRect)
}
}
fileprivate class MenuItemTextField: NSTextField {
override var allowsVibrancy: Bool {
return false
}
}
After calling setupMenuItemView(), i call menu.popup().
Hope this information helps.
I was unable to get your posted code to work correctly. The demo below is an alternative which uses a popUpContextual menu with subclassed text fields embedded in the menuItem views (note that the view associated with each menuItem is used and a custom view class is not created). Text alignment and truncation is functional. Menu width is also flexible and may be set to match width of the menu title field. The demo may be run in Xcode by copy/pasting source code into a newly added ‘main.swift’ file and additionally deleting Apple’s AppDelegate class.
import Cocoa
var view = [NSTextField]()
class TextField: NSTextField {
override func mouseDown(with event: NSEvent) {
print("selected = \(self.tag)")
for i:Int in 0..<view.count {
if(self.tag == i) {
view[i].backgroundColor = .lightGray
} else {
view[i].backgroundColor = .clear
}
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var window:NSWindow!
var menu = NSMenu()
let _menuLeft : CGFloat = 40
let _menuTop : CGFloat = 70
let _menuWidth : CGFloat = 70
let _menuItemH : CGFloat = 20
#objc func menuBtnAction(_ sender:AnyObject ) {
let menuOrigin = NSMakePoint(_menuLeft, sender.frame.origin.y - 5)
let wNum : Int = sender.window.windowNumber
let event = NSEvent.mouseEvent(with:.leftMouseDown, location:menuOrigin, modifierFlags:[], timestamp:0, windowNumber:wNum, context:nil, eventNumber:0, clickCount:1, pressure:1.0)
NSMenu.popUpContextMenu(menu, with: event!, for: window.contentView!)
}
func buildMenu() {
let mainMenu = NSMenu()
NSApp.mainMenu = mainMenu
// **** App menu **** //
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "Quit", action:#selector(NSApplication.terminate), keyEquivalent: "q")
}
func buildWnd() {
let _wndW : CGFloat = 400
let _wndH : CGFloat = 300
window = NSWindow(contentRect:NSMakeRect(0,0,_wndW,_wndH),styleMask:[.titled, .closable, .miniaturizable, .resizable], backing:.buffered, defer:false)
window.center()
window.title = "Swift Test Window"
window.makeKeyAndOrderFront(window)
let menuItems = ["10%","25%","50%","75%","100%","Longer text."]
menu.title = "Fit"
var count:Int = 0
for mItem in menuItems{
let menuItem = NSMenuItem()
menu.addItem(menuItem)
let textField = TextField(frame:NSMakeRect(0,0,_menuWidth, _menuItemH))
menuItem.view = textField
textField.alignment = .left // .left, .center, .right
textField.stringValue = mItem
textField.lineBreakMode = .byTruncatingTail
textField.backgroundColor = .clear
textField.isEditable = false
textField.tag = count
textField.isBordered = false
textField.font = NSFont( name:"Menlo bold", size:14 )
count = count + 1
view.append(textField)
}
// **** Menu title **** //
let label = NSTextField (frame:NSMakeRect( _menuLeft, _wndH - 50, _menuWidth, 24 ))
window.contentView!.addSubview (label)
label.autoresizingMask = [.maxXMargin,.minYMargin]
label.backgroundColor = .clear
label.lineBreakMode = .byTruncatingTail
label.isSelectable = false
label.isBordered = true
label.font = NSFont( name:"Menlo bold", size:14 )
label.stringValue = menu.title
// **** Menu Disclosure Button **** //
let menuBtn = NSButton (frame:NSMakeRect( (_menuLeft + _menuWidth) - 20, _wndH - 50, 20, 24 ))
menuBtn.bezelStyle = .disclosure
menuBtn.autoresizingMask = [.maxXMargin,.minYMargin]
menuBtn.title = ""
menuBtn.action = #selector(self.menuBtnAction(_:))
window.contentView!.addSubview (menuBtn)
// **** Quit btn **** //
let quitBtn = NSButton (frame:NSMakeRect( _wndW - 50, 10, 40, 40 ))
quitBtn.bezelStyle = .circular
quitBtn.autoresizingMask = [.minXMargin,.maxYMargin]
quitBtn.title = "Q"
quitBtn.action = #selector(NSApplication.terminate)
window.contentView!.addSubview(quitBtn)
}
func applicationDidFinishLaunching(_ notification: Notification) {
buildMenu()
buildWnd()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
let appDelegate = AppDelegate()
// **** main.swift **** //
let app = NSApplication.shared
app.delegate = appDelegate
app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps:true)
app.run()
Second possible alternative utilizes an NSPopUpButton with drop down menu which accommodates DarkMode by changing text color. As before, a subclassed text field is embedded into each menuItem.view to support text truncation and alignment. May be run in Xcode with instructions given previously.
import Cocoa
var view = [NSTextField]()
class TextField: NSTextField {
override func mouseDown(with event: NSEvent) {
print("selected = \(self.tag)")
for i:Int in 0..<view.count {
if(self.tag == i) {
view[i].backgroundColor = .lightGray
} else {
view[i].backgroundColor = .windowBackgroundColor
}
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var window:NSWindow!
var menu:NSMenu!
var pullDwn:NSPopUpButton!
var count:Int = 0
let _menuItemH : CGFloat = 20
func isDarkMode(view: NSView) -> Bool {
if #available(OSX 10.14, *) {
return view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
}
return false
}
#objc func myBtnAction(_ sender:Any ) {
print(pullDwn.index(of:sender as! NSMenuItem))
}
func buildMenu() {
let mainMenu = NSMenu()
NSApp.mainMenu = mainMenu
// **** App menu **** //
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "Quit", action:#selector(NSApplication.terminate), keyEquivalent: "q")
}
func buildWnd() {
let _wndW : CGFloat = 400
let _wndH : CGFloat = 300
window = NSWindow(contentRect:NSMakeRect(0,0,_wndW,_wndH),styleMask:[.titled, .closable, .miniaturizable, .resizable], backing:.buffered, defer:false)
window.center()
window.title = "Swift Test Window"
window.makeKeyAndOrderFront(window)
// **** NSPopUpButton with Menu **** //
let menuItems = ["10%","25%","50%","75%","100%","200%","300%","400%","800%","longertext"]
pullDwn = NSPopUpButton(frame:NSMakeRect(80, _wndH - 50, 80, 30), pullsDown:true)
pullDwn.autoresizingMask = [.maxXMargin,.minYMargin]
let menu = pullDwn.menu
for mItem in menuItems{
let menuItem = NSMenuItem()
menu?.addItem(menuItem)
menuItem.title = "Fit"
let textField = TextField(frame:NSMakeRect( 0, 0, pullDwn.frame.size.width, _menuItemH))
menuItem.view = textField
textField.alignment = .left // .left, .center, .right
textField.stringValue = mItem
textField.lineBreakMode = .byTruncatingTail
if (isDarkMode(view: textField)){
textField.textColor = .white
} else {
textField.textColor = .black
}
textField.backgroundColor = .windowBackgroundColor
textField.isEditable = false
textField.tag = count
textField.isBordered = false
textField.font = NSFont( name:"Menlo", size:14 )
count = count + 1
view.append(textField)
}
window.contentView!.addSubview (pullDwn)
// **** Quit btn **** //
let quitBtn = NSButton (frame:NSMakeRect( _wndW - 50, 10, 40, 40 ))
quitBtn.bezelStyle = .circular
quitBtn.autoresizingMask = [.minXMargin,.maxYMargin]
quitBtn.title = "Q"
quitBtn.action = #selector(NSApplication.terminate)
window.contentView!.addSubview(quitBtn)
}
func applicationDidFinishLaunching(_ notification: Notification) {
buildMenu()
buildWnd()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
let appDelegate = AppDelegate()
// **** main.swift **** //
let app = NSApplication.shared
app.delegate = appDelegate
app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps:true)
app.run()

How to add multi-line text using NSAttributedString to a NSButton?

My application supports multiple languages. I have a Translation object which sets string on NSButton Title. How can I use multiline to set text inside my Button?
I used self.lineBreakMode = .ByWordWrapping but it does not work.
class CustomNSButton: NSButton {
override func viewWillDraw() {
let currentText = Translations.shared.current?[self.identifier ?? ""]?.string ?? self.stringValue
self.lineBreakMode = .byWordWrapping
let size = calculateIdealFontSize(min: 5, max: 16)
let translatedString = CustomFormatter.string(for: currentText)
let pstyle = NSMutableParagraphStyle()
pstyle.alignment = .center
let translatedAttributedString = CustomFormatter.attributedString(for: translatedString ?? "", withDefaultAttributes:[NSFontAttributeName : NSFont(name: (self.font?.fontName)!, size: CGFloat(size))!, NSParagraphStyleAttributeName : pstyle])!
attributedTitle = translatedAttributedString
}
}
I created a multiline text by using let textLabel = NSTextField() and a let textFieldCell = CustomNSTextFieldCell() subclass. Add subview in CustomNSButton class addSubview(textLabel)
class CustomNSButton: NSButton {
let textLabel = NSTextField()
let textFieldCell = CustomNSTextFieldCell()
override func viewWillDraw() {
textLabel.frame = CGRect(x:0,y:0, width: frame.width - 2, height: frame.height - 2)
let pstyle = NSMutableParagraphStyle()
pstyle.alignment = .center
textLabel.attributedStringValue = CustomFormatter.attributedString(for: translatedString ?? "", withDefaultAttributes:[NSFontAttributeName : NSFont(name: (self.font?.fontName)!, size: CGFloat(size))!, NSParagraphStyleAttributeName : pstyle, NSForegroundColorAttributeName : NSColor.white])!
textLabel.isEditable = false
textLabel.isBezeled = false
textLabel.backgroundColor = NSColor.clear
textLabel.cell = textFieldCell
addSubview(textLabel)
}
}
class CustomNSTextFieldCell: NSTextFieldCell {
override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
let attrString: NSAttributedString? = attributedStringValue
attrString?.draw(with: titleRect(forBounds: cellFrame), options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin])
}
override func titleRect(forBounds theRect: NSRect) -> NSRect {
var tFrame: NSRect = super.titleRect(forBounds: theRect)
let attrString: NSAttributedString? = attributedStringValue
let tRect: NSRect? = attrString?.boundingRect(with: tFrame.size, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin])
if (textRect?.size.height)! < tFrame.size.height {
tFrame.origin.y = theRect.origin.y + (theRect.size.height - (textRect?.size.height)!) / 2.0
tFrame.size.height = (textRect?.size.height)!
}
return tFrame
}
}

Create a Login Page for SKSprite Game

I am in the process of creating a game (Swift) in xcode using a number of SKScene and Sprite objects. I want to create a Scene (settings scene) that captures the player's name, email, gender etc. How can I go about this? How can I capture input from user. SKScenes do not allow input fields/values in the UI?
Thanks
You can build a custom login page that is conform with your game layout without try to rebuild in UIKit the same graphic assets.
Few days ago I've written an answer about SKSceneDelegate to communicate between the scene(SpriteKit) and the viewController (UIKit), take present this answer if you want to call other viewControllers because its the same concept of this answer..
Starting with this GameViewController we can develop some useful methods to handle the login form buttons and show some alerts:
import UIKit
import SpriteKit
class GameViewController: UIViewController, TransitionDelegate {
override func viewDidLoad() {
super.viewDidLoad()
guard let view = self.view as! SKView? else { return }
view.ignoresSiblingOrder = true
view.showsFPS = true
view.showsNodeCount = true
let scene = GameScene(size:view.bounds.size)
scene.scaleMode = .fill
scene.delegate = self as TransitionDelegate
scene.anchorPoint = CGPoint.zero
view.presentScene(scene)
}
func showAlert(title:String,message:String) {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "Ok", style: .default) { action in
print("handle Ok action...")
})
alertController.addAction(UIAlertAction(title: "Cancel", style: UIAlertActionStyle.cancel, handler: nil))
self.present(alertController, animated: true)
}
func handleLoginBtn(username:String,password:String) {
print("handleLoginBtn")
print("username is: \(username) and password: \(password)")
}
func handleFacebookBtn() {
print("handleFacebookBtn")
}
func handleTwitterBtn() {
print("handleTwitterBtn")
}
}
Then we can make our scene trying to take the advantage of SpriteKit elements:
import SpriteKit
import UIKit
protocol TransitionDelegate: SKSceneDelegate {
func showAlert(title:String,message:String)
func handleLoginBtn(username:String,password:String)
func handleFacebookBtn()
func handleTwitterBtn()
}
class GameScene: SKScene,UITextFieldDelegate {
var usernameTextField:UITextField!
var passwordTextField:UITextField!
var loginBtn:SKShapeNode!
var facebookBtn:SKShapeNode!
var twitterBtn:SKShapeNode!
override func didMove(to view: SKView) {
//bg
let bg = SKSpriteNode(imageNamed: "appleWallpaper")
addChild(bg)
bg.position = CGPoint(x:self.size.width/2,y:self.size.height/2)
//title
let title = SKLabelNode.init(fontNamed: "AppleSDGothicNeo-Bold")
title.text = "xyzGame"; title.fontSize = 25
title.fontColor = .orange
addChild(title)
title.zPosition = 1
title.position = CGPoint(x:self.size.width/2,y:self.size.height-80)
//textfields
guard let view = self.view else { return }
let originX = (view.frame.size.width - view.frame.size.width/1.5)/2
usernameTextField = UITextField(frame: CGRect.init(x: originX, y: view.frame.size.height/4.5, width: view.frame.size.width/1.5, height: 30))
customize(textField: usernameTextField, placeholder: "Enter your username")
view.addSubview(usernameTextField)
usernameTextField.addTarget(self, action:#selector(GameScene.textFieldDidChange(textField:)), for: UIControlEvents.editingChanged)
passwordTextField = UITextField(frame: CGRect.init(x: originX, y: view.frame.size.height/4.5+60, width: view.frame.size.width/1.5, height: 30))
customize(textField: passwordTextField, placeholder: "Enter your password", isSecureTextEntry:true)
view.addSubview(passwordTextField)
//buttons
let myBlue = SKColor(colorLiteralRed: 59/255, green: 89/255, blue: 153/255, alpha: 1)
loginBtn = getButton(frame: CGRect(x:self.size.width/4,y:self.size.height/2,width:self.size.width/2,height:30),fillColor:myBlue,title:"Login",logo:nil,name:"loginBtn")
addChild(loginBtn)
loginBtn.zPosition = 1
let label = SKLabelNode.init(fontNamed: "AppleSDGothicNeo-Regular")
label.text = "or connect with"; label.fontSize = 15
label.fontColor = .gray
addChild(label)
label.zPosition = 1
label.position = CGPoint(x:self.size.width/2,y:self.size.height/2-30)
let logoFb = SKSpriteNode.init(imageNamed: "facebook-icon")
logoFb.setScale(0.5)
facebookBtn = getButton(frame: CGRect(x:self.size.width/4,y:self.size.height/2-80,width:self.size.width/4.5,height:30),fillColor:myBlue,logo:logoFb,name:"facebookBtn")
addChild(facebookBtn)
facebookBtn.zPosition = 1
let myCyan = SKColor(colorLiteralRed: 85/255, green: 172/255, blue: 239/255, alpha: 1)
let logoTw = SKSpriteNode.init(imageNamed: "twitter-icon")
logoTw.setScale(0.5)
twitterBtn = getButton(frame: CGRect(x:self.size.width/2,y:self.size.height/2-80,width:self.size.width/4.5,height:30),fillColor:myCyan,logo:logoTw,name:"twitterBtn")
addChild(twitterBtn)
twitterBtn.zPosition = 1
}
func customize(textField:UITextField, placeholder:String , isSecureTextEntry:Bool = false) {
let paddingView = UIView(frame:CGRect(x:0,y: 0,width: 10,height: 30))
textField.leftView = paddingView
textField.keyboardType = UIKeyboardType.emailAddress
textField.leftViewMode = UITextFieldViewMode.always
textField.attributedPlaceholder = NSAttributedString(string: placeholder,attributes: [NSForegroundColorAttributeName: UIColor.gray])
textField.autocapitalizationType = .none
textField.autocorrectionType = .no
textField.layer.borderColor = UIColor.gray.cgColor
textField.layer.borderWidth = 0.5
textField.layer.cornerRadius = 4.0
textField.textColor = .white
textField.isSecureTextEntry = isSecureTextEntry
textField.delegate = self
}
func getButton(frame:CGRect,fillColor:SKColor,title:String = "",logo:SKSpriteNode!,name:String)->SKShapeNode {
let btn = SKShapeNode(rect: frame, cornerRadius: 10)
btn.fillColor = fillColor
btn.strokeColor = fillColor
if let l = logo {
btn.addChild(l)
l.zPosition = 2
l.position = CGPoint(x:frame.origin.x+(frame.size.width/2),y:frame.origin.y+(frame.size.height/2))
l.name = name
}
if !title.isEmpty {
let label = SKLabelNode.init(fontNamed: "AppleSDGothicNeo-Regular")
label.text = title; label.fontSize = 15
label.fontColor = .white
btn.addChild(label)
label.zPosition = 3
label.position = CGPoint(x:frame.origin.x+(frame.size.width/2),y:frame.origin.y+(frame.size.height/4))
label.name = name
}
btn.name = name
return btn
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first
let positionInScene = touch!.location(in: self)
let touchedNode = self.atPoint(positionInScene)
if let name = touchedNode.name {
switch name {
case "loginBtn":
self.run(SKAction.wait(forDuration: 0.1),completion:{[unowned self] in
guard let delegate = self.delegate else { return }
(delegate as! TransitionDelegate).handleLoginBtn(username:self.usernameTextField.text!,password: self.passwordTextField.text!)
})
case "facebookBtn":
self.run(SKAction.wait(forDuration: 0.1),completion:{[unowned self] in
guard let delegate = self.delegate else { return }
(delegate as! TransitionDelegate).handleFacebookBtn()
})
case "twitterBtn":
self.run(SKAction.wait(forDuration: 0.1),completion:{[unowned self] in
guard let delegate = self.delegate else { return }
(delegate as! TransitionDelegate).handleTwitterBtn()
})
default:break
}
}
}
func textFieldDidChange(textField: UITextField) {
//print("everytime you type something this is fired..")
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
if textField == usernameTextField { // validate email syntax
let emailRegEx = "[A-Z0-9a-z._%+-]+#[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
let emailTest = NSPredicate(format:"SELF MATCHES %#", emailRegEx)
let result = emailTest.evaluate(with: textField.text)
let title = "Alert title"
let message = result ? "This is a correct email" : "Wrong email syntax"
if !result {
self.run(SKAction.wait(forDuration: 0.01),completion:{[unowned self] in
guard let delegate = self.delegate else { return }
(delegate as! TransitionDelegate).showAlert(title:title,message: message)
})
}
}
}
deinit {
print("\n THE SCENE \((type(of: self))) WAS REMOVED FROM MEMORY (DEINIT) \n")
}
}
Output:
Animated output:
As you can see we can handle both framework with their delegate methods, I've tested this page with iPhone 5 and iPhone 7 plus.