Created
December 30, 2022 18:06
-
-
Save umurgdk/f09ad68e59880a88e2be5703019346dd to your computer and use it in GitHub Desktop.
Mastodon AppKit cell view
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AppKit | |
import NukeUI | |
import PinLayout | |
final class FeedMediaGrid: BaseNSView { | |
let imageView1 = LazyImageView() | |
let imageView2 = LazyImageView() | |
let imageView3 = LazyImageView() | |
let imageView4 = LazyImageView() | |
var allImageViews: [LazyImageView] { | |
[imageView1, imageView2, imageView3, imageView4] | |
} | |
let preferredRatio: CGFloat = 1.75 | |
var imageCount = 1 | |
override var wantsUpdateLayer: Bool { true } | |
override func setupViewHieararchy() { | |
addSubview(imageView1) | |
addSubview(imageView2) | |
addSubview(imageView3) | |
addSubview(imageView4) | |
allImageViews.forEach { | |
$0.imageView.resizingMode = .fill | |
} | |
wantsLayer = true | |
layer?.cornerRadius = 6 | |
layer?.borderWidth = 1 | |
} | |
func configure(with attachments: [MediaAttachment]) { | |
let limitedAttachments = attachments.prefix(4) | |
zip(allImageViews, limitedAttachments).forEach { $0.url = $1.previewURL ?? $1.url } | |
imageCount = limitedAttachments.count | |
allImageViews.prefix(imageCount).forEach { $0.isHidden = false } | |
allImageViews.dropFirst(imageCount).forEach { $0.isHidden = true } | |
} | |
override func layout() { | |
super.layout() | |
if imageCount == 1 { | |
fillLayout() | |
} else if imageCount == 2 { | |
sideBySideLayout() | |
} else if imageCount == 3 { | |
brickLayout() | |
} else if imageCount == 4 { | |
gridLayout() | |
} | |
} | |
func fillLayout() { | |
imageView1.pin.all() | |
} | |
func sideBySideLayout() { | |
imageView1.pin.topLeft().bottom().right(to: edge.hCenter).marginRight(2) | |
imageView2.pin.topRight().bottom().left(to: edge.hCenter).marginLeft(2) | |
} | |
func brickLayout() { | |
imageView1.pin.topLeft().bottom().right(to: edge.hCenter).marginRight(2) | |
imageView2.pin.topRight().bottom(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginBottom(2) | |
imageView3.pin.bottomRight().top(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginTop(2) | |
} | |
func gridLayout() { | |
imageView1.pin.topLeft().bottom(to: edge.vCenter).right(to: edge.hCenter).marginRight(2).marginBottom(2) | |
imageView2.pin.topRight().bottom(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginBottom(2) | |
imageView3.pin.bottomLeft().top(to: edge.vCenter).right(to: edge.hCenter).marginRight(2).marginTop(2) | |
imageView4.pin.bottomRight().top(to: edge.vCenter).left(to: edge.hCenter).marginLeft(2).marginTop(2) | |
} | |
override func updateLayer() { | |
super.updateLayer() | |
if effectiveAppearance.name == NSAppearance.Name.darkAqua { | |
layer?.backgroundColor = .black | |
layer?.borderColor = NSColor.separatorColor.cgColor | |
} else { | |
layer?.backgroundColor = .white | |
layer?.borderColor = NSColor.black.withAlphaComponent(0.15).cgColor | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AppKit | |
import NukeUI | |
class FeedItemCell: BaseNSTableCellView, ReuseIdentifiable { | |
private let avatar = LazyImageView() | |
private let displayName = NSTextField(labelWithString: "") | |
private let username = NSTextField(labelWithString: "") | |
private let body = NSTextField(wrappingLabelWithString: "") | |
private let separator = NSBox() | |
private let mediaGrid = FeedMediaGrid() | |
private let typeView = FeedItemStatusView() | |
private let date = NSTextField(labelWithString: "") | |
private let actionBar = FeedItemActionBar() | |
override var isFlipped: Bool { false } | |
override class var requiresConstraintBasedLayout: Bool { true } | |
func configure(with status: Status, statusContentCache: inout [String: NSAttributedString]) { | |
let actualStatus = status.reblog ?? status | |
avatar.url = actualStatus.account.avatar | |
displayName.stringValue = actualStatus.account.displayName.isEmpty ? actualStatus.account.username : actualStatus.account.displayName | |
username.stringValue = actualStatus.account.username | |
body.attributedStringValue = statusContentCache[actualStatus.id] ?? renderStatusBody(actualStatus.content, into: &statusContentCache) | |
actionBar.configure( | |
repliesCount: actualStatus.numberOfReplies, | |
boostCount: actualStatus.numberOfReblogs, | |
isFavorited: false, | |
favoriteCount: actualStatus.numberOfFavorites, | |
isBookmarked: false) | |
date.stringValue = formatFeedItemDate(actualStatus.createdAt) | |
mediaGrid.configure(with: actualStatus.mediaAttachments) | |
if actualStatus.mediaAttachments.isEmpty { | |
mediaGrid.removeFromSuperview() | |
} else { | |
addSubview(mediaGrid) | |
} | |
if status.reblog != nil { | |
typeView.configure(type: .reblog, text: "\(status.account.displayName) boosted") | |
addSubview(typeView) | |
} else if status.replyingToStatusID != nil { | |
typeView.configure(type: .reply, text: "reply to another post") | |
} else { | |
typeView.removeFromSuperview() | |
} | |
} | |
func renderStatusBody(_ text: String, into cache: inout [String: NSAttributedString]) -> NSAttributedString { | |
let options: [NSMutableAttributedString.DocumentReadingOptionKey: Any] = [ | |
.documentType: NSAttributedString.DocumentType.html, | |
.characterEncoding: String.Encoding.utf8.rawValue, | |
] | |
let str = NSMutableAttributedString( | |
html: text.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8)!, | |
options: options, | |
documentAttributes: nil | |
)! | |
str.addAttributes([ | |
.font: NSFont.systemFont(ofSize: 14), | |
.foregroundColor: NSColor.labelColor | |
], range: NSRange(location: 0, length: str.length)) | |
var range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines) | |
// Trim leading characters from character set. | |
while range.length != 0 && range.location == 0 { | |
str.replaceCharacters(in: range, with: "") | |
range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines) | |
} | |
// Trim trailing characters from character set. | |
range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines, options: .backwards) | |
while range.length != 0 && NSMaxRange(range) == str.length { | |
str.replaceCharacters(in: range, with: "") | |
range = (str.string as NSString).rangeOfCharacter(from: .whitespacesAndNewlines, options: .backwards) | |
} | |
return str | |
} | |
override func prepareForReuse() { | |
super.prepareForReuse() | |
NSLayoutConstraint.deactivate(previousConstraints) | |
didCreateLayoutConstraints = false | |
needsUpdateConstraints = true | |
} | |
override func setupViewHieararchy() { | |
addSubview(avatar) | |
addSubview(displayName) | |
addSubview(username) | |
addSubview(body) | |
addSubview(separator) | |
addSubview(date) | |
addSubview(actionBar) | |
avatar.wantsLayer = true | |
avatar.layer?.cornerRadius = 6 | |
username.textColor = .tertiaryLabelColor | |
separator.boxType = .custom | |
separator.fillColor = .labelColor.withAlphaComponent(0.05) | |
separator.borderWidth = 0 | |
body.maximumNumberOfLines = 0 | |
body.lineBreakStrategy = .pushOut | |
body.lineBreakMode = .byWordWrapping | |
body.allowsEditingTextAttributes = true | |
date.textColor = .secondaryLabelColor | |
avatar.translatesAutoresizingMaskIntoConstraints = false | |
displayName.translatesAutoresizingMaskIntoConstraints = false | |
username.translatesAutoresizingMaskIntoConstraints = false | |
body.translatesAutoresizingMaskIntoConstraints = false | |
separator.translatesAutoresizingMaskIntoConstraints = false | |
mediaGrid.translatesAutoresizingMaskIntoConstraints = false | |
typeView.translatesAutoresizingMaskIntoConstraints = false | |
date.translatesAutoresizingMaskIntoConstraints = false | |
actionBar.translatesAutoresizingMaskIntoConstraints = false | |
displayName.setContentCompressionResistancePriority(.required, for: .vertical) | |
username.setContentCompressionResistancePriority(.required, for: .vertical) | |
body.setContentCompressionResistancePriority(.required, for: .vertical) | |
typeView.setContentCompressionResistancePriority(.required, for: .vertical) | |
date.setContentCompressionResistancePriority(.required, for: .vertical) | |
} | |
var didCreateLayoutConstraints = false | |
var previousConstraints: [NSLayoutConstraint] = [] | |
override func updateConstraints() { | |
if !didCreateLayoutConstraints { | |
didCreateLayoutConstraints = true | |
previousConstraints.removeAll() | |
let hasTypeView = typeView.superview == self | |
let hasMediaGrid = mediaGrid.superview == self | |
if hasTypeView { | |
previousConstraints += [ | |
typeView.topAnchor.constraint(equalTo: topAnchor, constant: 10), | |
typeView.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: -16), | |
typeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) | |
] | |
} | |
previousConstraints += [ | |
avatar.topAnchor.constraint( | |
equalTo: hasTypeView ? typeView.bottomAnchor : topAnchor, | |
constant: hasTypeView ? 4 : 16), | |
avatar.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), | |
avatar.widthAnchor.constraint(equalToConstant: 40), | |
avatar.heightAnchor.constraint(equalTo: avatar.widthAnchor), | |
displayName.topAnchor.constraint(equalTo: avatar.topAnchor, constant: 2), | |
displayName.leadingAnchor.constraint(equalTo: avatar.trailingAnchor, constant: 8), | |
displayName.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), | |
date.firstBaselineAnchor.constraint(equalTo: displayName.firstBaselineAnchor), | |
date.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), | |
username.topAnchor.constraint(equalTo: displayName.bottomAnchor, constant: 2), | |
username.leadingAnchor.constraint(equalTo: displayName.leadingAnchor), | |
username.trailingAnchor.constraint(equalTo: displayName.trailingAnchor), | |
body.topAnchor.constraint(equalTo: username.bottomAnchor, constant: 6), | |
body.leadingAnchor.constraint(equalTo: username.leadingAnchor), | |
body.trailingAnchor.constraint(equalTo: username.trailingAnchor), | |
separator.leadingAnchor.constraint(equalTo: leadingAnchor), | |
separator.bottomAnchor.constraint(equalTo: bottomAnchor), | |
separator.trailingAnchor.constraint(equalTo: trailingAnchor), | |
separator.heightAnchor.constraint(equalToConstant: 1) | |
] | |
if hasMediaGrid { | |
previousConstraints += [ | |
// We have media grid so media grid should have bottom constraint | |
mediaGrid.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 8), | |
mediaGrid.leadingAnchor.constraint(equalTo: body.leadingAnchor), | |
mediaGrid.trailingAnchor.constraint(equalTo: body.trailingAnchor), | |
mediaGrid.widthAnchor.constraint(equalTo: mediaGrid.heightAnchor, multiplier: 1.75) | |
] | |
} | |
previousConstraints += [ | |
hasMediaGrid | |
? actionBar.topAnchor.constraint(equalTo: mediaGrid.bottomAnchor, constant: 8) | |
: actionBar.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 8), | |
actionBar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), | |
actionBar.leadingAnchor.constraint(equalTo: body.leadingAnchor), | |
actionBar.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), | |
] | |
NSLayoutConstraint.activate(previousConstraints) | |
} | |
super.updateConstraints() | |
} | |
} |
Ooh that's a nice trick with the unavailable initWithCoder
, I'm stealing this! π
Lol, this is so annoying π
avatar.translatesAutoresizingMaskIntoConstraints = false
displayName.translatesAutoresizingMaskIntoConstraints = false
username.translatesAutoresizingMaskIntoConstraints = false
body.translatesAutoresizingMaskIntoConstraints = false
separator.translatesAutoresizingMaskIntoConstraints = false
mediaGrid.translatesAutoresizingMaskIntoConstraints = false
typeView.translatesAutoresizingMaskIntoConstraints = false
date.translatesAutoresizingMaskIntoConstraints = false
actionBar.translatesAutoresizingMaskIntoConstraints = false
Haha appkit/uikit developer ergonomics
β¦On 30 Dec 2022 Fri at 22:35 Kuba Suder ***@***.***> wrote:
***@***.**** commented on this gist.
------------------------------
Lol, this is so annoying π
avatar.translatesAutoresizingMaskIntoConstraints = false
displayName.translatesAutoresizingMaskIntoConstraints = false
username.translatesAutoresizingMaskIntoConstraints = false
body.translatesAutoresizingMaskIntoConstraints = false
separator.translatesAutoresizingMaskIntoConstraints = false
mediaGrid.translatesAutoresizingMaskIntoConstraints = false
typeView.translatesAutoresizingMaskIntoConstraints = false
date.translatesAutoresizingMaskIntoConstraints = false
actionBar.translatesAutoresizingMaskIntoConstraints = false
β
Reply to this email directly, view it on GitHub
<https://gist.github.com/f09ad68e59880a88e2be5703019346dd#gistcomment-4418657>
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAEYSTUIQSCBUSLUYFKZY4LWP42R5BFKMF2HI4TJMJ2XIZLTSKBKK5TBNR2WLJDHNFZXJJDOMFWWLK3UNBZGKYLEL52HS4DFQKSXMYLMOVS2I5DSOVS2I3TBNVS3W5DIOJSWCZC7OBQXE5DJMNUXAYLOORPWCY3UNF3GS5DZVRZXKYTKMVRXIX3UPFYGLK2HNFZXIQ3PNVWWK3TUUZ2G64DJMNZZDAVEOR4XAZNEM5UXG5FFOZQWY5LFVEYTEMBQGU3TMMBQU52HE2LHM5SXFJTDOJSWC5DF>
.
You are receiving this email because you authored the thread.
Triage notifications on the go with GitHub Mobile for iOS
<https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
or Android
<https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>
.
I don't understand the while range.length != 0 && range.location == 0 { ...
part - shouldn't the text.trimmingCharacters(in: .whitespacesAndNewlines)
in the initializer already have trimmed everything there is to trim on the beginning/end? Why would there still be whitespace to trim there after that?
@mackuba please ignore that part π€£ , I was just reading that part while pasting into gist and I was like, what the hell am i doing there?
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
There is nothing special about the
BaseNSView
andBaseNSTableCellView
they basically implementrequired init(coder: NSCoder)
and marks it asunavailable
so that subclasses doesn't have to implement that unnecessary initializer:One little convenient method defined on
BaseNSView
issetupViewHieararchy
: