Skip to content

Instantly share code, notes, and snippets.

@stephancasas
Created April 30, 2023 20:53
Show Gist options
  • Save stephancasas/2457054575746f1101e69a5f7b3edd6a to your computer and use it in GitHub Desktop.
Save stephancasas/2457054575746f1101e69a5f7b3edd6a to your computer and use it in GitHub Desktop.
An NSHostingView inline inside an NSTextView
//
// InlineHostTextView.swift
// InlineHostTextView
//
// Created by Stephan Casas on 4/30/23.
//
import SwiftUI;
import AppKit;
import Combine;
// MARK: - Sample Usage / Main Content View
struct ContentView: View {
var body: some View {
InlineHostTextView(
"You have {{ content }} new messages.",
contentWidth: 30,
contentHeight: 30
){
IncrementingCounter();
}.font(.boldSystemFont(ofSize: 22))
VStack {
Button("Increment", action: {countPublisher.send(1)});
Spacer();
}
}
}
// MARK: - Sample Counter View
let countPublisher = PassthroughSubject<Int, Never>();
struct IncrementingCounter: View {
@State var count: Int = 0;
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 12.5)
.foregroundColor(.purple)
.shadow(radius: 2.0)
HStack{
Spacer()
Text("\(count)")
.foregroundStyle(.white)
.font(.system(size: 18, weight: .light))
.onReceive(countPublisher) { increment in
self.count += increment;
}
Spacer();
}
}
}
}
// MARK: - Custom SwiftUI NSViewRepresentable with NSTextView and NSHostingView
struct InlineHostTextView<Content: View>: NSViewRepresentable {
private var font: NSFont;
private var color: NSColor;
let displayString: String;
let contentAnchor: Int;
let contentView: () -> Content;
let contentSize: NSSize;
init(
_ withString: String,
usingFont: NSFont = .systemFont(ofSize: 14, weight: .semibold),
presentingColor: NSColor = .secondaryLabelColor,
replacingText: String = "{{ content }}",
contentWidth: CGFloat,
contentHeight: CGFloat,
_ content: @escaping () -> Content
) {
guard let replaceIndex = withString.range(of: replacingText) else {
fatalError("Replacement text must be present in given string.");
}
self.contentAnchor = withString.distance(
from: withString.startIndex,
to: replaceIndex.lowerBound);
self.displayString = withString.replacingOccurrences(
of: replacingText,
with: ""
);
self.font = usingFont;
self.color = presentingColor;
self.contentSize = NSSize(width: contentWidth, height: contentHeight);
self.contentView = content;
}
/// Set the color for the display string of this view.
func color(_ color: NSColor) -> Self {
var copy = self;
copy.color = color;
return copy;
}
/// Set the font for the display string of this view.
func font(_ font: NSFont) -> Self {
var copy = self;
copy.font = font;
return copy;
}
func makeNSView(context: Context) -> OffsetTextView {
let textContainer = NSTextContainer(containerSize: NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
));
textContainer.widthTracksTextView = true;
textContainer.heightTracksTextView = false;
textContainer.lineFragmentPadding = 0;
textContainer.maximumNumberOfLines = 1;
let layoutManager = NSLayoutManager();
layoutManager.addTextContainer(textContainer);
let textStorage = NSTextStorage();
textStorage.addLayoutManager(layoutManager);
let textView = OffsetTextView();
textView.isEditable = false;
textView.autoresizingMask = [.width, .height];
textView.drawsBackground = false;
textView.allowsUndo = true;
textView.isAutomaticTextCompletionEnabled = false;
textView.string = self.displayString;
textView.alignment = .center
textView.font = self.font;
textView.textColor = .secondaryLabelColor;
let maxHeight: CGFloat = textView.font!.boundingRectForFont.height;
textView.offsetY = maxHeight/2;
let attachment = NSTextAttachment();
attachment.attachmentCell = InlineHostAttachmentCell(
size: self.contentSize,
offsetY: maxHeight/4,
contentView);
textView.textStorage?.insert(
NSAttributedString(attachment: attachment),
at: self.contentAnchor);
return textView;
}
func updateNSView(_ nsView: OffsetTextView, context: Context) { }
// MARK: - NSTextView with Vertical Offset
class OffsetTextView: NSTextView {
var offsetY: CGFloat = 0;
override func draw(_ dirtyRect: NSRect) {
if self.offsetY != 0 {
textContainerInset = NSSize(width: 0, height: offsetY)
}
super.draw(dirtyRect)
}
}
// MARK: - NSTextAttachmentCell with NSHostingView
class InlineHostAttachmentCell<Content: View>: NSTextAttachmentCell {
let contentView: () -> Content;
let __size: NSSize;
let __offsetY: CGFloat;
init(size: NSSize, offsetY: CGFloat, _ content: @escaping () -> Content) {
self.contentView = content;
self.__size = size;
self.__offsetY = offsetY;
super.init();
}
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
guard let controlView = controlView else {
return;
}
let contentHost = NSHostingView(rootView: contentView());
controlView.addSubview(contentHost);
contentHost.frame = NSRect(
x: cellFrame.origin.x,
y: cellFrame.origin.y + self.__offsetY,
width: cellFrame.width,
height: cellFrame.height);
}
override func cellSize() -> NSSize {
self.__size;
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}
@stephancasas
Copy link
Author

94D74834-7AFA-4281-BE83-5C9025091E68

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment