blob: f7d38fd38737342a1cae17daa42735aef6293565 [file] [log] [blame]
/*
* Copyright (C) 2015 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
WI.View = class View extends WI.Object
{
constructor(element)
{
super();
this._element = element || document.createElement("div");
this._element.__view = this;
this._parentView = null;
this._subviews = [];
this._dirty = false;
this._dirtyDescendantsCount = 0;
this._isAttachedToRoot = false;
this._layoutReason = null;
this._didInitialLayout = false;
}
// Static
static fromElement(element)
{
if (!element || !(element instanceof HTMLElement))
return null;
if (element.__view instanceof WI.View)
return element.__view;
return null;
}
static rootView()
{
if (!WI.View._rootView) {
// Since the root view is attached by definition, it does not go through the
// normal view attachment process. Simply mark it as attached.
WI.View._rootView = new WI.View(document.body);
WI.View._rootView._isAttachedToRoot = true;
}
return WI.View._rootView;
}
// Public
get element() { return this._element; }
get layoutPending() { return this._dirty; }
get parentView() { return this._parentView; }
get subviews() { return this._subviews; }
get isAttached() { return this._isAttachedToRoot; }
isDescendantOf(view)
{
let parentView = this._parentView;
while (parentView) {
if (parentView === view)
return true;
parentView = parentView.parentView;
}
return false;
}
addSubview(view)
{
this.insertSubviewBefore(view, null);
}
insertSubviewBefore(view, referenceView)
{
console.assert(view instanceof WI.View);
console.assert(!referenceView || referenceView instanceof WI.View);
console.assert(view !== WI.View._rootView, "Root view cannot be a subview.");
console.assert(!view.parentView, view);
if (this._subviews.includes(view)) {
console.assert(false, "Cannot add view that is already a subview.", view);
return;
}
const beforeIndex = referenceView ? this._subviews.indexOf(referenceView) : this._subviews.length;
if (beforeIndex === -1) {
console.assert(false, "Cannot insert view. Invalid reference view.", referenceView);
return;
}
this._subviews.insertAtIndex(view, beforeIndex);
console.assert(!view.element.parentNode || this._element.contains(view.element.parentNode), "Subview DOM element must be a descendant of the parent view element.");
if (!view.element.parentNode)
this._element.insertBefore(view.element, referenceView ? referenceView.element : null);
view._didMoveToParent(this);
}
removeSubview(view)
{
console.assert(view instanceof WI.View);
console.assert(this._element.contains(view.element), "Subview DOM element must be a child of the parent view element.");
let index = this._subviews.lastIndexOf(view);
if (index === -1) {
console.assert(false, "Cannot remove view which isn't a subview.", view);
return;
}
view._didMoveToParent(null);
this._subviews.splice(index, 1);
view.element.remove();
}
removeAllSubviews()
{
for (let subview of this._subviews)
subview._didMoveToParent(null);
this._subviews = [];
this._element.removeChildren();
}
replaceSubview(oldView, newView)
{
console.assert(oldView !== newView, "Cannot replace subview with itself.");
if (oldView === newView)
return;
this.insertSubviewBefore(newView, oldView);
this.removeSubview(oldView);
}
updateLayout(layoutReason)
{
this._setLayoutReason(layoutReason);
this._layoutSubtree();
}
updateLayoutIfNeeded(layoutReason)
{
if (!this._dirty && this._didInitialLayout)
return;
this.updateLayout(layoutReason);
}
needsLayout(layoutReason)
{
this._setLayoutReason(layoutReason);
if (this._dirty)
return;
WI.View._scheduleLayoutForView(this);
}
// Protected
get layoutReason() { return this._layoutReason; }
get didInitialLayout() { return this._didInitialLayout; }
attached()
{
// Implemented by subclasses.
}
detached()
{
// Implemented by subclasses.
}
initialLayout()
{
// Implemented by subclasses.
// Called once when the view is shown for the first time.
// Views with complex DOM subtrees should create UI elements in
// initialLayout rather than at construction time.
}
layout()
{
// Implemented by subclasses.
// Not responsible for recursing to child views.
// Should not be called directly; use updateLayout() instead.
}
didLayoutSubtree()
{
// Implemented by subclasses.
// Called after the view and its entire subtree have finished layout.
}
sizeDidChange()
{
// Implemented by subclasses.
// Called after initialLayout, and before layout.
}
// Private
_setDirty(dirty)
{
if (this._dirty === dirty)
return;
this._dirty = dirty;
for (let parentView = this.parentView; parentView; parentView = parentView.parentView) {
parentView._dirtyDescendantsCount += this._dirty ? 1 : -1;
console.assert(parentView._dirtyDescendantsCount >= 0);
}
}
_didMoveToParent(parentView)
{
if (this._parentView === parentView)
return;
console.assert(this._parentView || !(this._isDirty || this._dirtyDescendantsCount));
let dirtyDescendantsCount = this._dirtyDescendantsCount;
if (this._dirty)
++dirtyDescendantsCount;
if (dirtyDescendantsCount) {
for (let view = this.parentView; view; view = view.parentView) {
view._dirtyDescendantsCount -= dirtyDescendantsCount;
console.assert(view._dirtyDescendantsCount >= 0);
}
}
this._parentView = parentView;
let isAttachedToRoot = this.isDescendantOf(WI.View._rootView);
let views = [this];
for (let i = 0; i < views.length; ++i) {
let view = views[i];
views.pushAll(view.subviews);
view._dirty = false;
view._dirtyDescendantsCount = 0;
if (view._isAttachedToRoot === isAttachedToRoot)
continue;
view._isAttachedToRoot = isAttachedToRoot;
if (view._isAttachedToRoot)
view.attached();
else
view.detached();
}
if (isAttachedToRoot)
WI.View._scheduleLayoutForView(this);
}
_layoutSubtree()
{
this._setDirty(false);
let isInitialLayout = !this._didInitialLayout;
if (isInitialLayout) {
console.assert(WI.setReentrantCheck(this, "initialLayout"), "ERROR: calling `initialLayout` while already in it", this);
this.initialLayout();
this._didInitialLayout = true;
}
if (this._layoutReason === WI.View.LayoutReason.Resize || isInitialLayout) {
console.assert(WI.setReentrantCheck(this, "sizeDidChange"), "ERROR: calling `sizeDidChange` while already in it", this);
this.sizeDidChange();
console.assert(WI.clearReentrantCheck(this, "sizeDidChange"), "ERROR: missing return from `sizeDidChange`", this);
}
let savedLayoutReason = this._layoutReason;
if (isInitialLayout) {
// The initial layout should always be treated as dirty.
this._setLayoutReason();
}
console.assert(WI.setReentrantCheck(this, "layout"), "ERROR: calling `layout` while already in it", this);
this.layout();
console.assert(WI.clearReentrantCheck(this, "layout"), "ERROR: missing return from `layout`", this);
// Ensure that the initial layout override doesn't affects to subviews.
this._layoutReason = savedLayoutReason;
if (WI.settings.debugEnableLayoutFlashing.value)
this._drawLayoutFlashingOutline(isInitialLayout);
for (let view of this._subviews) {
view._setLayoutReason(this._layoutReason);
view._layoutSubtree();
}
this._layoutReason = null;
console.assert(WI.setReentrantCheck(this, "didLayoutSubtree"), "ERROR: calling `didLayoutSubtree` while already in it", this);
this.didLayoutSubtree();
console.assert(WI.clearReentrantCheck(this, "didLayoutSubtree"), "ERROR: missing return from `didLayoutSubtree`", this);
}
_setLayoutReason(layoutReason)
{
this._layoutReason = layoutReason || WI.View.LayoutReason.Dirty;
}
_drawLayoutFlashingOutline(isInitialLayout)
{
if (this._layoutFlashingTimeout)
clearTimeout(this._layoutFlashingTimeout);
else
this._layoutFlashingPreviousOutline = this._element.style.outline;
let hue = isInitialLayout ? 20 : 40;
this._element.style.outline = `1px solid hsla(${hue}, 100%, 51%, 0.8)`;
this._layoutFlashingTimeout = setTimeout(() => {
if (this._element)
this._element.style.outline = this._layoutFlashingPreviousOutline;
this._layoutFlashingTimeout = undefined;
this._layoutFlashingPreviousOutline = null;
}, 500);
}
// Layout controller logic
static _scheduleLayoutForView(view)
{
if (!view._isAttachedToRoot)
return;
view._setDirty(true);
if (WI.View._scheduledLayoutUpdateIdentifier)
return;
WI.View._scheduledLayoutUpdateIdentifier = requestAnimationFrame(WI.View._visitViewTreeForLayout);
}
static _visitViewTreeForLayout()
{
console.assert(WI.View._rootView, "Cannot layout view tree without a root.");
WI.View._scheduledLayoutUpdateIdentifier = undefined;
let views = [WI.View._rootView];
for (let i = 0; i < views.length; ++i) {
let view = views[i];
if (view.layoutPending)
view._layoutSubtree();
else if (view._dirtyDescendantsCount)
views.pushAll(view.subviews);
}
}
};
WI.View.LayoutReason = {
Dirty: Symbol("layout-reason-dirty"),
Resize: Symbol("layout-reason-resize")
};
WI.View._rootView = null;
WI.View._scheduledLayoutUpdateIdentifier = undefined;