I have been using an UIImageView subclass, found here, to set asynchronously-downloaded images inside of UITableViewCells, keeping the correct aspect ratio. That class follows:
internal final class ScaleAspectFitImageView: UIImageView {
/// Constraint to maintain same aspect ratio as the image.
private var aspectRatioConstraint: NSLayoutConstraint? = nil
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
public override init(frame:CGRect) {
super.init(frame: frame)
setup()
}
public override init(image: UIImage!) {
super.init(image: image)
setup()
}
public override init(image: UIImage!, highlightedImage: UIImage?) {
super.init(image: image, highlightedImage: highlightedImage)
setup()
}
override public var image: UIImage? {
didSet {
print("\nUpdating aspect ratio constraints")
updateAspectRatioConstraint()
print("Updated aspect ratio constraints\n")
}
}
private func setup() {
contentMode = .scaleAspectFit
updateAspectRatioConstraint()
}
/// Removes any pre-existing aspect ratio constraint, and adds a new one based on the current image
private func updateAspectRatioConstraint() {
// Remove any existing aspect ratio constraint
if let constraint = aspectRatioConstraint {
removeConstraint(constraint)
}
aspectRatioConstraint = nil
if let imageSize = image?.size, imageSize.height != 0 {
let aspectRatio = imageSize.width / imageSize.height
let constraint = NSLayoutConstraint(item: self, attribute: .width, relatedBy: .equal, toItem: self, attribute: .height, multiplier: aspectRatio, constant: 0)
addConstraint(constraint)
aspectRatioConstraint = constraint
}
}
}
I am also using AlamofireImage to download the images inside of tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath), which follows:
cell.embeddedImageImageView.layer.borderColor = UIColor.red.cgColor
cell.embeddedImageImageView.layer.borderWidth = 2
let placeholderImage = #imageLiteral(resourceName: "postImagePlaceholder")
if let embeddedImageURL = message.mediaURL {
let filter = AspectScaledToFitSizeFilter(size: cell.embeddedImageImageView.frame.size)
cell.embeddedImageImageView.af_setImage(withURL: embeddedImageURL, placeholderImage: placeholderImage, filter: filter)
} else {
cell.embeddedImageImageView.image = placeholderImage
}
However, when the images download, they are placed inside of the cell's embeddedImageImageView, resulting in incorrect sizes.
Following is a screenshot of my cells, where I have placed a red border around the UIImageView, which is the ScaleAspectFitImageView subclass.
What am I doing incorrectly that is causing my images to not be sized properly?
Did you make your cell's image view a subclass of ScaleAspectFitImageView in the first place?
Running the subclass you posted causes EXC_BAD_ACCESS. So I deleted the aspectRatioConstraint property and their usage and ran your code, everything is working perfectly fine.
Related
I am applying a perspective Core Image filter to transform and draw a CIImage into a custom NSView and it seems slower than I expected (e.g, I drag a slider that alters the perspective transformation and the drawing lags behind the slider value). Here is my custom drawRect method where self.mySourceImage is a CIImage:
- (void)drawRect:(NSRect)dirtyRect {
[super drawRect:dirtyRect];
if (self.perspectiveFilter == nil)
self.perspectiveFilter = [CIFilter filterWithName:#"CIPerspectiveTransform"];
[self.perspectiveFilter setValue:self.mySourceImage
forKey:#"inputImage"];
[self.perspectiveFilter setValue: [CIVector vectorWithX:0 Y:0]
forKey:#"inputBottomLeft"];
// ... set other vector parameters based on slider value
CIImage *outputImage = [self.perspectiveFilter outputImage];
[outputImage drawInRect:dstrect
fromRect:srcRect
operation:NSCompositingOperationSourceOver
fraction:0.8];
}
Here is an example output:
My experience with image filters tells me that this should be much faster. Is there some "best practice" that I am missing to speed this up?
Note that I only create the filter once (stored as a property).
I did make sure the view has a CALayer for a backing store. Should I be adding the filter to a CALayer somehow?
Note that I never create a CIContext -- I assume there is an implicit context used by NSView? Should I create a CIContext and render to an image and draw the image?
Here's how I use a GLKView in UIKit:
I prefer subclassing GLKView to allow for a few things:
initializing from code
overriding draw(rect:) for the UIImageView equivalence of contentMode (aspect fit in particular)
when using scaleAspectFit, creating a "clear color" for the background color to match the surrounding superviews
That said, here's what I have:
import GLKit
class ImageView: GLKView {
var renderContext: CIContext
var rgb:(Int?,Int?,Int?)!
var myClearColor:UIColor!
var clearColor: UIColor! {
didSet {
myClearColor = clearColor
}
}
var image: CIImage! {
didSet {
setNeedsDisplay()
}
}
var uiImage:UIImage? {
get {
let final = renderContext.createCGImage(self.image, from: self.image.extent)
return UIImage(cgImage: final!)
}
}
init() {
let eaglContext = EAGLContext(api: .openGLES2)
renderContext = CIContext(eaglContext: eaglContext!)
super.init(frame: CGRect.zero)
context = eaglContext!
self.translatesAutoresizingMaskIntoConstraints = false
}
override init(frame: CGRect, context: EAGLContext) {
renderContext = CIContext(eaglContext: context)
super.init(frame: frame, context: context)
enableSetNeedsDisplay = true
self.translatesAutoresizingMaskIntoConstraints = false
}
required init?(coder aDecoder: NSCoder) {
let eaglContext = EAGLContext(api: .openGLES2)
renderContext = CIContext(eaglContext: eaglContext!)
super.init(coder: aDecoder)
context = eaglContext!
self.translatesAutoresizingMaskIntoConstraints = false
}
override func draw(_ rect: CGRect) {
if let image = image {
let imageSize = image.extent.size
var drawFrame = CGRect(x: 0, y: 0, width: CGFloat(drawableWidth), height: CGFloat(drawableHeight))
let imageAR = imageSize.width / imageSize.height
let viewAR = drawFrame.width / drawFrame.height
if imageAR > viewAR {
drawFrame.origin.y += (drawFrame.height - drawFrame.width / imageAR) / 2.0
drawFrame.size.height = drawFrame.width / imageAR
} else {
drawFrame.origin.x += (drawFrame.width - drawFrame.height * imageAR) / 2.0
drawFrame.size.width = drawFrame.height * imageAR
}
rgb = myClearColor.rgb()
glClearColor(Float(rgb.0!)/256.0, Float(rgb.1!)/256.0, Float(rgb.2!)/256.0, 0.0);
glClear(0x00004000)
// set the blend mode to "source over" so that CI will use that
glEnable(0x0BE2);
glBlendFunc(1, 0x0303);
renderContext.draw(image, in: drawFrame, from: image.extent)
}
}
}
A few notes:
The vast majority of this was taken from something written a few years back (in Swift 2 I think) from objc.io with the associated GitHub project. In particular, check out their GLKView subclass that has code for scaleAspectFill and other content modes.
Note the usage of a single CIContext called renderContext. I use it to create a UIImage when needed (in iOS you "share" a UIImage).
I use a didSet with the image property to automatically call setNeedsDisplay when the image changes. (I also call this explicitly when an iOS device changes orientation.) I do not know the macOS equivalent of this call.
I hope this gives you a good start for using OpenGL in macOS. If it's anything like UIKit, trying to put a CIImage in an NSView doesn't involve the GPU, which is a bad thing.
I want to edit border width and background color of an NSView and made those values IBInspectable:
#IBInspectable var borderWidth: CGFloat{
set{
layer?.borderWidth = newValue
}
get{
return layer!.borderWidth
}
}
#IBInspectable var backgroundColor: NSColor{
set{
layer?.backgroundColor = newValue.CGColor
}
get{
return NSColor(CGColor: layer!.backgroundColor)!
}
}
In the init method, I wrote:
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.setNeedsDisplay()
}
When I run the app, changes are shown correctly, but the view doesn't live-render in the interface builder. I don't get any errors or warnings either.
i found the solution myself: it seems that the init Method does not get called when the view gets rendered in the Interface builder. As a solution i had to add a global variable which creates a CALayer() when needed:
#IBDesignable class CDPProgressIndicator: NSView {
// creates a CALayer() and sets it to the layer property of the view
var mainLayer: CALayer{
get{
if layer == nil{
layer = CALayer()
}
return layer!
}
}
//referencing to mainLayer ensures, that the CALayer is not nil
#IBInspectable var borderWidth: CGFloat{
set{
mainLayer.borderWidth = newValue
}
get{
return mainLayer.borderWidth
}
}
#IBInspectable var backgroundColor: NSColor{
set{
mainLayer.backgroundColor = newValue.CGColor
}
get{
return NSColor(CGColor: mainLayer.backgroundColor)!
}
}
and now it finally renders in the interface builder as well.
The setting of wantsLayer is sufficient to have the layer created when running the app, but it doesn’t work in IB. You have to set the layer explicitly. I’d suggest doing this in prepareForInterfaceBuilder, that way you are using the approved technique, wantsLayer, in your app, but we handle the IB exception, too. For example:
#IBDesignable
class CustomView: NSView {
#IBInspectable var borderWidth: CGFloat {
didSet { layer!.borderWidth = borderWidth }
}
#IBInspectable var borderColor: NSColor {
didSet { layer!.borderColor = borderColor.cgColor }
}
#IBInspectable var backgroundColor: NSColor {
didSet { layer!.backgroundColor = backgroundColor.cgColor }
}
// needed because IB doesn't don't honor `wantsLayer`
override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
layer = CALayer()
configure()
}
override init(frame: NSRect = .zero) {
super.init(frame: frame)
configure()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
configure()
}
private func configure() {
wantsLayer = true
...
}
}
That handles this IB edge case (bug?), but doesn’t alter the standard flow when running the app.
So I am used to UIImageView, and being able to set different ways of how its image is displayed in it. Like for example AspectFill mode etc...
I would like to accomplish the same thing using NSImageView on a mac app. Does NSImageView work similarly to UIImageView in that regard or how would I go about showing an image in an NSImageView and picking different ways of displaying that image?
You may find it much easier to subclass NSView and provide a CALayer that does the aspect fill for you. Here is what the init might look like for this NSView subclass.
- (id)initWithFrame:(NSRect)frame andImage:(NSImage*)image
{
self = [super initWithFrame:frame];
if (self) {
self.layer = [[CALayer alloc] init];
self.layer.contentsGravity = kCAGravityResizeAspectFill;
self.layer.contents = image;
self.wantsLayer = YES;
}
return self;
}
Note that the order of setting the layer, then settings wantsLayer is very important (if you set wantsLayer first, you'll get a default backing layer instead).
You could have a setImage method that simply updates the contents of the layer.
Here is what I'm using, written with Swift. This approach works well with storyboards - just use a normal NSImageView, then replace the name NSImageView in the Class box, with MyAspectFillImageNSImageView ...
open class MyAspectFillImageNSImageView : NSImageView {
open override var image: NSImage? {
set {
self.layer = CALayer()
self.layer?.contentsGravity = kCAGravityResizeAspectFill
self.layer?.contents = newValue
self.wantsLayer = true
super.image = newValue
}
get {
return super.image
}
}
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
}
//the image setter isn't called when loading from a storyboard
//manually set the image if it is already set
required public init?(coder: NSCoder) {
super.init(coder: coder)
if let theImage = image {
self.image = theImage
}
}
}
I had the same problem. I wanted to have the image to be scaled to fill but keeping the aspect ratio of the original image. Strangely, this is not as simple as it seems, and does not come out of the box with NSImageView. I wanted the NSImageView scale nicely while it resize with superview(s). I made a drop-in NSImageView subclass you can find on github: KPCScaleToFillNSImageView
You can use this: image will be force to fill the view size
( Aspect Fill )
imageView.imageScaling = .scaleAxesIndependently
( Aspect Fit )
imageView.imageScaling = .scaleProportionallyUpOrDown
( Center Top )
imageView.imageScaling = .scaleProportionallyDown
It works for me.
I was having an hard time trying to figure out how you can make an Aspect Fill Clip to Bounds :
Picture credit: https://osxentwicklerforum.de/index.php/Thread/28812-NSImageView-Scaling-Seitenverh%C3%A4ltnis/
Finally I made my own Subclass of NSImageView, hope this can help someone :
import Cocoa
#IBDesignable
class NSImageView_ScaleAspectFill: NSImageView {
#IBInspectable
var scaleAspectFill : Bool = false
override func awakeFromNib() {
// Scaling : .scaleNone mandatory
if scaleAspectFill { self.imageScaling = .scaleNone }
}
override func draw(_ dirtyRect: NSRect) {
if scaleAspectFill, let _ = self.image {
// Compute new Size
let imageViewRatio = self.image!.size.height / self.image!.size.width
let nestedImageRatio = self.bounds.size.height / self.bounds.size.width
var newWidth = self.image!.size.width
var newHeight = self.image!.size.height
if imageViewRatio > nestedImageRatio {
newWidth = self.bounds.size.width
newHeight = self.bounds.size.width * imageViewRatio
} else {
newWidth = self.bounds.size.height / imageViewRatio
newHeight = self.bounds.size.height
}
self.image!.size.width = newWidth
self.image!.size.height = newHeight
}
// Draw AFTER resizing
super.draw(dirtyRect)
}
}
Plus this is #IBDesignable so you can set it on in the StoryBoard
WARNINGS
I'm new to MacOS Swift development, I come from iOS development that's why I was surprised I couldn't find a clipToBound property, maybe it exists and I wasn't able to find it !
Regarding the code, I suspect this is consuming a lot, and also this has the side effect to modify the original image ratio over the time. This side effect seemed negligible to me.
Once again if their is a setting that allow a NSImageView to clip to bounds, please remove this answer :]
Image scalling can be updated with below function of NSImageView.
[imageView setImageScaling:NSScaleProportionally];
Here are more options to change image display property.
enum {
NSScaleProportionally = 0, // Deprecated. Use NSImageScaleProportionallyDown
NSScaleToFit, // Deprecated. Use NSImageScaleAxesIndependently
NSScaleNone // Deprecated. Use NSImageScaleNone
};
Here is another approach which uses SwiftUI under the hood
The major advantage here is that if your image has dark & light modes, then they are respected when the system appearance changes
(I couldn't get that to work with the other approaches)
This relies on an image existing in your assets with imageName
import Foundation
import AppKit
import SwiftUI
open class AspectFillImageView : NSView {
#IBInspectable
open var imageName: String?
{
didSet {
if imageName != oldValue {
insertSwiftUIImage(imageName)
}
}
}
open override func prepareForInterfaceBuilder() {
self.needsLayout = true
}
func insertSwiftUIImage(_ name:String?){
self.removeSubviews()
guard let name = name else {
return
}
let iv = Image(name).resizable().scaledToFill()
let hostView = NSHostingView(rootView:iv)
self.addSubview(hostView)
//I'm using PureLayout to pin the subview. You will have to rewrite this in your own way...
hostView.autoPinEdgesToSuperviewEdges()
}
func commonInit() {
insertSwiftUIImage(imageName)
}
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
commonInit()
}
required public init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
}
Answers already given here are very good, but most of them involve subclassing NSView or NSImageView.
You could also achieve the result just by using a CALayer. But in that case you wouldn't have auto layout capabilities.
The simplest solution is to have a NSView, without subclassing it, and setting manually it's layer property. It could also be a NSImageView and achieve the same result.
Example
Using Swift
let view = NSView()
view.layer = .init() // CALayer()
view.layer?.contentsGravity = .resizeAspectFill
view.layer?.contents = image // image is a NSImage, could also be a CGImage
view.wantsLayer = true
I have set the intercell spacing in my NSTableView to 0 by sending:
[self.tableView setIntercellSpacing:NSMakeSize(0, 0)];
in the window controller's awakeFromNib but there is still an (possibly 1 pixel wide) empty space between the rows, which I think is where the grid lines are drawn although I'm not using the grid lines. How can I get rid of this space between the rows?
update:
The NSTableView documentation seems to say that this 1 pixel separation should go away when the intercell separation is set to 0, 0. In my case, its not. Maybe it's a bug?
As suggested by trudyscousin, I'll post how I fixed my problem:
As it turns out, the empty space does in fact disappear when you set the intercell spacing to 0 as I did. My problem was that the drawing code in my NSTableCellView subclass wasn't drawing all the way to the edge of the view. The gap I was seeing wasn't the intercell separation, it was the border of my NSTableCellView subclass.
In my case the task was to have 0.5 pt (1 px on Retina display) separator. Seems even when intercellSpacing set to .zero (or 0.5 pt in my case) AppKit still preserving 1 pt space between rows when drawing selection.
I ended up by subclassing NSTableRowView. With custom row I can set separator to any height. Here is a Swift 4.2 example:
class WelcomeRecentDocumentsView: View {
private lazy var tableView = TableView().autolayoutView()
private lazy var scrollView = ScrollView(tableView: tableView).autolayoutView()
override var intrinsicContentSize: NSSize {
return CGSize(width: 240, height: 400)
}
private var recentDocumentsURLs = (0 ..< 10).map { $0 }
}
extension WelcomeRecentDocumentsView: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return recentDocumentsURLs.count
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
return tableView.makeView(ItemView.self)
}
}
extension WelcomeRecentDocumentsView: NSTableViewDelegate {
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
// Returning custom RowView.
return RowView(separatorHeight: separatorHeight, separatorColor: tableView.gridColor)
}
}
extension WelcomeRecentDocumentsView {
private var separatorHeight: CGFloat {
return convertFromBacking(1)
}
override func setupUI() {
addSubview(scrollView)
let column = NSTableColumn(identifier: "com.example.column", resizingMask: .autoresizingMask)
tableView.addTableColumn(column)
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
tableView.headerView = nil
tableView.setAutomaticRowHeight(estimatedHeight: ItemView.defaultHeight)
tableView.intercellSpacing = CGSize(height: separatorHeight)
tableView.gridStyleMask = .solidHorizontalGridLineMask
}
override func setupHandlers() {
tableView.delegate = self
tableView.dataSource = self
}
override func setupLayout() {
LayoutConstraint.pin(to: .bounds, scrollView).activate()
}
override func setupDefaults() {
tableView.sizeLastColumnToFit()
}
}
extension WelcomeRecentDocumentsView {
class RowView: NSTableRowView {
let separatorHeight: CGFloat
let separatorColor: NSColor
init(separatorHeight: CGFloat = 1, separatorColor: NSColor = .gridColor) {
self.separatorHeight = separatorHeight
self.separatorColor = separatorColor
super.init(frame: .zero)
}
required init?(coder decoder: NSCoder) {
fatalError()
}
override func drawSelection(in dirtyRect: NSRect) {
let rect = bounds.insetBottom(by: -separatorHeight)
NSColor.alternateSelectedControlColor.setFill()
rect.fill()
}
override func drawSeparator(in dirtyRect: NSRect) {
let rect = bounds.insetTop(by: bounds.height - separatorHeight)
separatorColor.setFill()
rect.fill()
}
}
class ItemView: View {
static let defaultHeight: CGFloat = 52
override var intrinsicContentSize: NSSize {
return CGSize(intrinsicHeight: type(of: self).defaultHeight)
}
}
}
Is there a way to customize the color of a NSPopUpButton arrow? I've looked around but I've not found an answer yet
I really dont think there is an "easy" way to do this. If you look at the API description, it even states that it doesnt respond to the setImage routine. I have done quite a bit of work sub-classing button objects, etc... and I think this is where you would have to go in order to do what you are asking.
Like too many of these controls, I did it by subclassing NSPopupButton(Cell) and then doing all my own drawing in drawRect...I cheated a little though, and used an image do the actual triangle rather than trying to do it via primitives.
- (void)drawRect:(NSRect)dirtyRect
{
//...Insert button draw code here...
//Admittedly the above statement includes more work than we probably want to do.
//Assumes triangleIcon is a cached NSImage...I also make assumptions about location
CGFloat iconSize = 6.0;
CGFloat iconYLoc = (dirtyRect.size.height - iconSize) / 2.0;
CGFloat iconXLoc = (dirtyRect.size.width - (iconSize + 8));
CGRect triRect = {iconXLoc, iconYLoc, iconSize, iconSize};
[triangleIcon drawInRect:triRect];
}
i did this and its worked for me.
(void)drawImageWithFrame:(NSRect)cellFrame inView:(NSView *)controlView
{
NSPopUpButton *temp = (NSPopUpButton*)controlView;
NSString *strtile = temp.title;
AppDelegate *appdel = (AppDelegate*)[NSApplication sharedApplication].delegate;
NSFont *font = [NSFont systemFontOfSize:13.5];
NSSize size = NSMakeSize(40, 10);// string size
CGRect rect = controlView.frame;
rect = CGRectMake((size.width + temp.frame.size.width)/2, rect.origin.y, 8, 17);
[self drawImage:[NSImage imageNamed:#"icon_downArrow_white.png"] withFrame:rect inView:self.
}
I have changed arrow color by using "False Color" filter without using any image. So far it is the easiest way to change cocoa control to me.
class RLPopUpButton: NSPopUpButton {
init() {
super.init(frame: NSZeroRect, pullsDown: false)
addFilter()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
addFilter()
}
func addFilter() {
let colorFilter = CIFilter(name: "CIFalseColor")!
colorFilter.setDefaults()
colorFilter.setValue(CIColor(cgColor: NSColor.black.cgColor), forKey: "inputColor0")
colorFilter.setValue(CIColor(cgColor: NSColor.white.cgColor), forKey: "inputColor1")
// colorFilter.setValue(CIColor(cgColor: NSColor.yellow.cgColor), forKey: "inputColor0")
// colorFilter.setValue(CIColor(cgColor: NSColor.property.cgColor), forKey: "inputColor1")
self.contentFilters = [colorFilter]
}
}
Swift 5
In interface builder, remove default arrow setting.
Then, apply this subclass for cell, which will add an NSImageView to the right side of the NSPopUpButton.
This way you have complete control over what you set as your custom button and how you position it.
import Cocoa
#IBDesignable class NSPopUpButtonCellBase: NSPopUpButtonCell {
let textColor = NSColor(named: "white")!
let leftPadding: CGFloat = 16
let rightPadding: CGFloat = 30
override func awakeFromNib() {
super.awakeFromNib()
let imageView = NSImageView()
imageView.image = NSImage(named: "ic_chevron_down")!
controlView!.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.widthAnchor.constraint(equalToConstant: CGFloat(20)).isActive = true
imageView.heightAnchor.constraint(equalToConstant: CGFloat(20)).isActive = true
imageView.trailingAnchor.constraint(equalTo: controlView!.trailingAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: controlView!.centerYAnchor).isActive = true
}
// overriding this removes the white container
override func drawBezel(withFrame frame: NSRect, in controlView: NSView) {
}
// overriding this allows us to modify paddings to text
override func titleRect(forBounds cellFrame: NSRect) -> NSRect {
// this gets rect, which has title's height, not the whole control's height
// also, it's origin.y is such that it centers title
let processedTitleFrame = super.titleRect(forBounds: cellFrame)
let paddedFrame = NSRect(
x: cellFrame.origin.x + leftPadding,
y: processedTitleFrame.origin.y,
width: cellFrame.size.width - leftPadding - rightPadding,
height: processedTitleFrame.size.height
)
return paddedFrame
}
// overriding this allows us to style text
override func drawTitle(_ title: NSAttributedString, withFrame frame: NSRect, in controlView: NSView) -> NSRect {
let attributedTitle = NSMutableAttributedString.init(attributedString: title)
let range = NSMakeRange(0, attributedTitle.length)
attributedTitle.addAttributes([NSAttributedString.Key.foregroundColor : textColor], range: range)
return super.drawTitle(attributedTitle, withFrame: frame, in: controlView)
}
}