001package arez.dom; 002 003import arez.ComputableValue; 004import arez.Disposable; 005import arez.annotations.Action; 006import arez.annotations.ArezComponent; 007import arez.annotations.ComputableValueRef; 008import arez.annotations.DepType; 009import arez.annotations.Feature; 010import arez.annotations.Memoize; 011import arez.annotations.Observable; 012import arez.annotations.OnActivate; 013import arez.annotations.OnDeactivate; 014import java.util.Objects; 015import javax.annotation.Nonnull; 016import jsinterop.annotations.JsFunction; 017import jsinterop.annotations.JsMethod; 018import jsinterop.annotations.JsPackage; 019import jsinterop.annotations.JsProperty; 020import jsinterop.annotations.JsType; 021 022/** 023 * An observable model that indicates whether a window matches a CSS media query. 024 * 025 * <p>A very simple example</p> 026 * <pre>{@code 027 * import arez.Arez; 028 * import arez.dom.MediaQuery; 029 * import com.google.gwt.core.client.EntryPoint; 030 * 031 * public class MediaQueryExample 032 * implements EntryPoint 033 * { 034 * public void onModuleLoad() 035 * { 036 * final MediaQuery mediaQuery = MediaQuery.create( "(max-width: 600px)" ); 037 * Arez.context().observer( () -> 038 * System.out.println( "Screen size Status: " + 039 * ( mediaQuery.matches() ? "Narrow" : "Wide" ) ) ); 040 * } 041 * } 042 * }</pre> 043 */ 044@ArezComponent( requireId = Feature.DISABLE ) 045public abstract class MediaQuery 046{ 047 /** 048 * Minimal facade for the browser window media-query API. 049 */ 050 @JsType( isNative = true, name = "Window", namespace = JsPackage.GLOBAL ) 051 public static class Window 052 { 053 protected Window() 054 { 055 } 056 057 @JsMethod 058 native MediaQueryList matchMedia( String query ); 059 } 060 061 @JsFunction 062 private interface EventListener 063 { 064 void handleEvent( Object event ); 065 } 066 067 @JsType( isNative = true, name = "MediaQueryList", namespace = JsPackage.GLOBAL ) 068 private static class MediaQueryList 069 { 070 @JsProperty( name = "media" ) 071 native String media(); 072 073 @JsProperty( name = "matches" ) 074 native boolean matches(); 075 076 @JsMethod 077 native void addListener( EventListener listener ); 078 079 @JsMethod 080 native void removeListener( EventListener listener ); 081 } 082 083 @Nonnull 084 private final EventListener _listener = e -> notifyOnMatchChange(); 085 @Nonnull 086 private final Window _window; 087 @Nonnull 088 private MediaQueryList _mediaQueryList; 089 private boolean _active; 090 091 /** 092 * Create an instance of MediaQuery. 093 * 094 * @param query the CSS media query to match. 095 * @return the MediaQuery instance. 096 */ 097 @Nonnull 098 public static MediaQuery create( @Nonnull final String query ) 099 { 100 return create( window(), query ); 101 } 102 103 /** 104 * Create an instance of MediaQuery. 105 * 106 * @param window the window to test. 107 * @param query the CSS media query to match. 108 * @return the MediaQuery instance. 109 */ 110 @Nonnull 111 public static MediaQuery create( @Nonnull final Window window, @Nonnull final String query ) 112 { 113 return new Arez_MediaQuery( window, query ); 114 } 115 116 MediaQuery( @Nonnull final Window window, @Nonnull final String query ) 117 { 118 _window = Objects.requireNonNull( window ); 119 _mediaQueryList = _window.matchMedia( Objects.requireNonNull( query ) ); 120 } 121 122 /** 123 * Return the window against which the MediaQuery will run. 124 * 125 * @return the window to test. 126 */ 127 @Nonnull 128 public Window getWindow() 129 { 130 return _window; 131 } 132 133 /** 134 * Return the media query to test against window. 135 * 136 * @return the associated media query. 137 */ 138 @Nonnull 139 @Observable 140 public String getQuery() 141 { 142 return _mediaQueryList.media(); 143 } 144 145 /** 146 * Change the media query to test against. 147 * If the component is active then invoking this will ensure that the listener is updated to listen 148 * to new query. 149 * 150 * @param query the CSS media query. 151 */ 152 public void setQuery( @Nonnull final String query ) 153 { 154 if ( _active ) 155 { 156 unbindListener(); 157 } 158 _mediaQueryList = _window.matchMedia( Objects.requireNonNull( query ) ); 159 if ( _active ) 160 { 161 bindListener(); 162 } 163 } 164 165 /** 166 * Return true if the media query matches, false otherwise. 167 * 168 * @return true if the media query matches, false otherwise. 169 */ 170 @Memoize( depType = DepType.AREZ_OR_EXTERNAL ) 171 public boolean matches() 172 { 173 // Observe query so that this is re-calculated if query changes 174 getQuery(); 175 return _mediaQueryList.matches(); 176 } 177 178 @ComputableValueRef 179 abstract ComputableValue<?> getMatchesComputableValue(); 180 181 @OnActivate 182 void onMatchesActivate() 183 { 184 _active = true; 185 bindListener(); 186 } 187 188 @OnDeactivate 189 void onMatchesDeactivate() 190 { 191 _active = false; 192 unbindListener(); 193 } 194 195 @Action 196 void notifyOnMatchChange() 197 { 198 // According to notes in https://github.com/ReactTraining/react-media/blob/master/modules/MediaQueryList.js 199 // Safari doesn't clear up listener with removeListener when the listener is already waiting in the event queue. 200 // This code makes sure Having an active flag to make sure the change is not reported after computable is 201 // deactivated and component is disposed. 202 if ( !Disposable.isDisposed( this ) ) 203 { 204 getMatchesComputableValue().reportPossiblyChanged(); 205 } 206 } 207 208 private void bindListener() 209 { 210 _mediaQueryList.addListener( _listener ); 211 } 212 213 private void unbindListener() 214 { 215 _mediaQueryList.removeListener( _listener ); 216 } 217 218 @JsProperty( name = "window", namespace = JsPackage.GLOBAL ) 219 private static native Window window(); 220}