001package arez.dom; 002 003import akasha.EventListener; 004import akasha.EventTarget; 005import arez.ComputableValue; 006import arez.Disposable; 007import arez.annotations.Action; 008import arez.annotations.ArezComponent; 009import arez.annotations.ComputableValueRef; 010import arez.annotations.DepType; 011import arez.annotations.Feature; 012import arez.annotations.Memoize; 013import arez.annotations.Observable; 014import arez.annotations.OnActivate; 015import arez.annotations.OnDeactivate; 016import java.util.Objects; 017import javax.annotation.Nonnull; 018import jsinterop.annotations.JsFunction; 019 020/** 021 * Generic component that exposes a property as observable where changes to the variable are signalled 022 * using a browser event. A typical example is making the value of <code>window.innerWidth</code> 023 * observable by listening to <code>"resize"</code> events on the window. This could be achieved with code such 024 * as: 025 * 026 * <pre>{@code 027 * EventDrivenValue<Window, Integer> innerWidth = EventDrivenValue.create( window, "resize", () -> window.innerWidth ) 028 * }</pre> 029 * 030 * <p>It is important that the code not add a listener to the underlying event source until there is an 031 * observer accessing the <code>"value"</code> observable defined by the EventDrivenValue class. The first 032 * observer that observes the observable will result in an event listener being added to the event source 033 * and this listener will not be removed until there is no observers left observing the value. This means 034 * that a component that is not being used has very little overhead.</p> 035 * 036 * @param <SourceType> the type of the DOM element that generates events of interest. 037 * @param <ValueType> the type of the value returned by the "value" observable. 038 */ 039@ArezComponent( requireId = Feature.DISABLE, disposeNotifier = Feature.DISABLE ) 040public abstract class EventDrivenValue<SourceType extends EventTarget, ValueType> 041{ 042 /** 043 * The functional interface defining accessor. 044 * 045 * @param <SourceType> the type of the DOM element that generates events of interest. 046 * @param <ValueType> the type of the value returned by the "value" observable. 047 */ 048 @FunctionalInterface 049 @JsFunction 050 public interface Accessor<SourceType extends EventTarget, ValueType> 051 { 052 /** 053 * Return the value. 054 * 055 * @param source the source that drives the access. 056 * @return the value 057 */ 058 ValueType get( @Nonnull SourceType source ); 059 } 060 061 /** 062 * The 063 */ 064 @Nonnull 065 private final EventListener _listener = e -> onEvent(); 066 @Nonnull 067 private SourceType _source; 068 @Nonnull 069 private final String _event; 070 @Nonnull 071 private final Accessor<SourceType, ValueType> _getter; 072 private boolean _active; 073 074 /** 075 * Create the component. 076 * 077 * @param <SourceType> the type of the DOM element that generates events of interest. 078 * @param <ValueType> the type of the value returned by the "value" observable. 079 * @param source the DOM element that generates events of interest. 080 * @param event the event type that could result in changes to the observed value. The event type is expected to be generated by the source element. 081 * @param getter the function that retrieves the observed value from the platform. 082 * @return the new component. 083 */ 084 @Nonnull 085 public static <SourceType extends EventTarget, ValueType> 086 EventDrivenValue<SourceType, ValueType> create( @Nonnull final SourceType source, 087 @Nonnull final String event, 088 @Nonnull final Accessor<SourceType, ValueType> getter ) 089 { 090 return new Arez_EventDrivenValue<>( source, event, getter ); 091 } 092 093 EventDrivenValue( @Nonnull final SourceType source, 094 @Nonnull final String event, 095 @Nonnull final Accessor<SourceType, ValueType> getter ) 096 { 097 _source = Objects.requireNonNull( source ); 098 _event = Objects.requireNonNull( event ); 099 _getter = Objects.requireNonNull( getter ); 100 } 101 102 /** 103 * Return the element that generates the events that report potential changes to the observed value. 104 * 105 * @return the associated element. 106 */ 107 @Nonnull 108 @Observable 109 public SourceType getSource() 110 { 111 return _source; 112 } 113 114 /** 115 * Set the element that generates events. 116 * This ensures that the event listeners are managed correctly if the source is currently being observed. 117 * 118 * @param source the the event source. 119 */ 120 public void setSource( @Nonnull final SourceType source ) 121 { 122 if ( _active ) 123 { 124 unbindListener(); 125 } 126 _source = source; 127 if ( _active ) 128 { 129 bindListener(); 130 } 131 } 132 133 /** 134 * Return the value. 135 * 136 * @return the value. 137 */ 138 @Memoize( depType = DepType.AREZ_OR_EXTERNAL ) 139 public ValueType getValue() 140 { 141 // Deliberately observing source via getSource() so that this method re-runs 142 // when source changes 143 return _getter.get( getSource() ); 144 } 145 146 @ComputableValueRef 147 abstract ComputableValue<?> getValueComputableValue(); 148 149 /** 150 * Hook invoked when the value moves from unobserved to observed. 151 * Adds underlying listener. 152 */ 153 @OnActivate 154 void onValueActivate() 155 { 156 _active = true; 157 bindListener(); 158 } 159 160 /** 161 * Hook invoked when value is no longer observed. 162 * Removes underlying listener. 163 */ 164 @OnDeactivate 165 void onValueDeactivate() 166 { 167 _active = false; 168 unbindListener(); 169 } 170 171 private void onEvent() 172 { 173 // Due to bugs (?) or perhaps "implementation choices" in some browsers, an event can be delivered 174 // after listener is removed. According to notes in https://github.com/ReactTraining/react-media/blob/master/modules/MediaQueryList.js 175 // Safari doesn't clear up listener queue on MediaQueryList when removeListener is called if there 176 // is already waiting in the internal event queue. 177 // 178 // To avoid a potential crash when invariants are enabled or indeterminate behaviour when invariants 179 // are not enabled, a guard has been added. 180 if ( Disposable.isNotDisposed( this ) ) 181 { 182 notifyOnChange(); 183 } 184 } 185 186 /** 187 * Hook invoked from listener to indicate memoized value should be recomputed. 188 */ 189 @Action 190 void notifyOnChange() 191 { 192 getValueComputableValue().reportPossiblyChanged(); 193 } 194 195 /** 196 * Add underlying listener to source. 197 */ 198 private void bindListener() 199 { 200 _source.addEventListener( _event, _listener ); 201 } 202 203 /** 204 * Remove underlying listener from source. 205 */ 206 private void unbindListener() 207 { 208 _source.removeEventListener( _event, _listener ); 209 } 210}